Repository: cjpais/Handy Branch: main Commit: 8836d4553261 Files: 249 Total size: 1.4 MB Directory structure: gitextract_qhrvhh5a/ ├── .cargo/ │ └── config.toml ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── config.yml │ ├── PULL_REQUEST_TEMPLATE.md │ └── workflows/ │ ├── build-test.yml │ ├── build.yml │ ├── code-quality.yml │ ├── main-build.yml │ ├── nix-check.yml │ ├── playwright.yml │ ├── pr-test-build.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .nix/ │ ├── bun-lock-hash │ └── bun.nix ├── .prettierignore ├── .prettierrc ├── .vscode/ │ └── extensions.json ├── AGENTS.md ├── BUILD.md ├── CHANGELOG.md ├── CLAUDE.md ├── CONTRIBUTING.md ├── CONTRIBUTING_TRANSLATIONS.md ├── CRUSH.md ├── LICENSE ├── README.md ├── eslint.config.js ├── flake.nix ├── index.html ├── nix/ │ ├── hm-module.nix │ └── module.nix ├── package.json ├── playwright.config.ts ├── scripts/ │ ├── check-nix-deps.ts │ └── check-translations.ts ├── src/ │ ├── App.css │ ├── App.tsx │ ├── bindings.ts │ ├── components/ │ │ ├── AccessibilityPermissions.tsx │ │ ├── Sidebar.tsx │ │ ├── footer/ │ │ │ ├── Footer.tsx │ │ │ └── index.ts │ │ ├── icons/ │ │ │ ├── CancelIcon.tsx │ │ │ ├── HandyHand.tsx │ │ │ ├── HandyTextLogo.tsx │ │ │ ├── MicrophoneIcon.tsx │ │ │ ├── ResetIcon.tsx │ │ │ ├── TranscriptionIcon.tsx │ │ │ └── index.ts │ │ ├── model-selector/ │ │ │ ├── DownloadProgressDisplay.tsx │ │ │ ├── ModelDropdown.tsx │ │ │ ├── ModelSelector.tsx │ │ │ ├── ModelStatusButton.tsx │ │ │ └── index.ts │ │ ├── onboarding/ │ │ │ ├── AccessibilityOnboarding.tsx │ │ │ ├── ModelCard.tsx │ │ │ ├── Onboarding.tsx │ │ │ └── index.ts │ │ ├── settings/ │ │ │ ├── AccelerationSelector.tsx │ │ │ ├── AlwaysOnMicrophone.tsx │ │ │ ├── AppDataDirectory.tsx │ │ │ ├── AppLanguageSelector.tsx │ │ │ ├── AppendTrailingSpace.tsx │ │ │ ├── AudioFeedback.tsx │ │ │ ├── AutoSubmit.tsx │ │ │ ├── AutostartToggle.tsx │ │ │ ├── ClamshellMicrophoneSelector.tsx │ │ │ ├── ClipboardHandling.tsx │ │ │ ├── CustomWords.tsx │ │ │ ├── ExperimentalToggle.tsx │ │ │ ├── GlobalShortcutInput.tsx │ │ │ ├── HandyKeysShortcutInput.tsx │ │ │ ├── HistoryLimit.tsx │ │ │ ├── LanguageSelector.tsx │ │ │ ├── LazyStreamClose.tsx │ │ │ ├── MicrophoneSelector.tsx │ │ │ ├── ModelUnloadTimeout.tsx │ │ │ ├── MuteWhileRecording.tsx │ │ │ ├── OutputDeviceSelector.tsx │ │ │ ├── PasteMethod.tsx │ │ │ ├── PostProcessingSettingsApi/ │ │ │ │ ├── ApiKeyField.tsx │ │ │ │ ├── BaseUrlField.tsx │ │ │ │ ├── ModelSelect.tsx │ │ │ │ ├── ProviderSelect.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── types.ts │ │ │ │ └── usePostProcessProviderState.ts │ │ │ ├── PostProcessingSettingsPrompts.tsx │ │ │ ├── PostProcessingToggle.tsx │ │ │ ├── PushToTalk.tsx │ │ │ ├── RecordingRetentionPeriod.tsx │ │ │ ├── ShortcutInput.tsx │ │ │ ├── ShowOverlay.tsx │ │ │ ├── ShowTrayIcon.tsx │ │ │ ├── SoundPicker.tsx │ │ │ ├── StartHidden.tsx │ │ │ ├── TranslateToEnglish.tsx │ │ │ ├── TypingTool.tsx │ │ │ ├── UpdateChecksToggle.tsx │ │ │ ├── VolumeSlider.tsx │ │ │ ├── about/ │ │ │ │ └── AboutSettings.tsx │ │ │ ├── advanced/ │ │ │ │ └── AdvancedSettings.tsx │ │ │ ├── debug/ │ │ │ │ ├── DebugPaths.tsx │ │ │ │ ├── DebugSettings.tsx │ │ │ │ ├── KeyboardImplementationSelector.tsx │ │ │ │ ├── LogDirectory.tsx │ │ │ │ ├── LogLevelSelector.tsx │ │ │ │ ├── PasteDelay.tsx │ │ │ │ ├── RecordingBuffer.tsx │ │ │ │ ├── WordCorrectionThreshold.tsx │ │ │ │ └── index.ts │ │ │ ├── general/ │ │ │ │ ├── GeneralSettings.tsx │ │ │ │ └── ModelSettingsCard.tsx │ │ │ ├── history/ │ │ │ │ └── HistorySettings.tsx │ │ │ ├── index.ts │ │ │ ├── models/ │ │ │ │ ├── ModelsSettings.tsx │ │ │ │ └── index.ts │ │ │ └── post-processing/ │ │ │ └── PostProcessingSettings.tsx │ │ ├── shared/ │ │ │ ├── ProgressBar.tsx │ │ │ └── index.ts │ │ ├── ui/ │ │ │ ├── Alert.tsx │ │ │ ├── AudioPlayer.tsx │ │ │ ├── Badge.tsx │ │ │ ├── Button.tsx │ │ │ ├── Dropdown.tsx │ │ │ ├── Input.tsx │ │ │ ├── PathDisplay.tsx │ │ │ ├── ResetButton.tsx │ │ │ ├── Select.tsx │ │ │ ├── SettingContainer.tsx │ │ │ ├── SettingsGroup.tsx │ │ │ ├── Slider.tsx │ │ │ ├── TextDisplay.tsx │ │ │ ├── Textarea.tsx │ │ │ ├── ToggleSwitch.tsx │ │ │ ├── Tooltip.tsx │ │ │ └── index.ts │ │ └── update-checker/ │ │ ├── UpdateChecker.tsx │ │ └── index.ts │ ├── hooks/ │ │ ├── useOsType.ts │ │ └── useSettings.ts │ ├── i18n/ │ │ ├── index.ts │ │ ├── languages.ts │ │ └── locales/ │ │ ├── ar/ │ │ │ └── translation.json │ │ ├── cs/ │ │ │ └── translation.json │ │ ├── de/ │ │ │ └── translation.json │ │ ├── en/ │ │ │ └── translation.json │ │ ├── es/ │ │ │ └── translation.json │ │ ├── fr/ │ │ │ └── translation.json │ │ ├── it/ │ │ │ └── translation.json │ │ ├── ja/ │ │ │ └── translation.json │ │ ├── ko/ │ │ │ └── translation.json │ │ ├── pl/ │ │ │ └── translation.json │ │ ├── pt/ │ │ │ └── translation.json │ │ ├── ru/ │ │ │ └── translation.json │ │ ├── tr/ │ │ │ └── translation.json │ │ ├── uk/ │ │ │ └── translation.json │ │ ├── vi/ │ │ │ └── translation.json │ │ ├── zh/ │ │ │ └── translation.json │ │ └── zh-TW/ │ │ └── translation.json │ ├── lib/ │ │ ├── constants/ │ │ │ └── languages.ts │ │ ├── types/ │ │ │ └── events.ts │ │ └── utils/ │ │ ├── format.ts │ │ ├── keyboard.ts │ │ ├── modelTranslation.ts │ │ └── rtl.ts │ ├── main.tsx │ ├── overlay/ │ │ ├── RecordingOverlay.css │ │ ├── RecordingOverlay.tsx │ │ ├── index.html │ │ └── main.tsx │ ├── stores/ │ │ ├── modelStore.ts │ │ └── settingsStore.ts │ ├── utils/ │ │ └── dateFormat.ts │ └── vite-env.d.ts ├── src-tauri/ │ ├── .gitignore │ ├── Cargo.toml │ ├── Entitlements.plist │ ├── Info.plist │ ├── build.rs │ ├── capabilities/ │ │ ├── default.json │ │ └── desktop.json │ ├── gen/ │ │ └── apple/ │ │ └── PrivacyInfo.xcprivacy │ ├── icons/ │ │ └── icon.icns │ ├── nsis/ │ │ └── installer.nsi │ ├── resources/ │ │ ├── default_settings.json │ │ └── models/ │ │ ├── gigaam_vocab.txt │ │ └── silero_vad_v4.onnx │ ├── rustfmt.toml │ ├── src/ │ │ ├── actions.rs │ │ ├── apple_intelligence.rs │ │ ├── audio_feedback.rs │ │ ├── audio_toolkit/ │ │ │ ├── audio/ │ │ │ │ ├── device.rs │ │ │ │ ├── mod.rs │ │ │ │ ├── recorder.rs │ │ │ │ ├── resampler.rs │ │ │ │ ├── utils.rs │ │ │ │ └── visualizer.rs │ │ │ ├── bin/ │ │ │ │ └── cli.rs │ │ │ ├── constants.rs │ │ │ ├── mod.rs │ │ │ ├── text.rs │ │ │ ├── utils.rs │ │ │ └── vad/ │ │ │ ├── mod.rs │ │ │ ├── silero.rs │ │ │ └── smoothed.rs │ │ ├── cli.rs │ │ ├── clipboard.rs │ │ ├── commands/ │ │ │ ├── audio.rs │ │ │ ├── history.rs │ │ │ ├── mod.rs │ │ │ ├── models.rs │ │ │ └── transcription.rs │ │ ├── helpers/ │ │ │ ├── clamshell.rs │ │ │ └── mod.rs │ │ ├── input.rs │ │ ├── lib.rs │ │ ├── llm_client.rs │ │ ├── main.rs │ │ ├── managers/ │ │ │ ├── audio.rs │ │ │ ├── history.rs │ │ │ ├── mod.rs │ │ │ ├── model.rs │ │ │ ├── transcription.rs │ │ │ └── transcription_mock.rs │ │ ├── overlay.rs │ │ ├── portable.rs │ │ ├── settings.rs │ │ ├── shortcut/ │ │ │ ├── handler.rs │ │ │ ├── handy_keys.rs │ │ │ ├── mod.rs │ │ │ └── tauri_impl.rs │ │ ├── signal_handle.rs │ │ ├── transcription_coordinator.rs │ │ ├── tray.rs │ │ ├── tray_i18n.rs │ │ └── utils.rs │ ├── swift/ │ │ ├── apple_intelligence.swift │ │ ├── apple_intelligence_bridge.h │ │ └── apple_intelligence_stub.swift │ └── tauri.conf.json ├── tailwind.config.js ├── tests/ │ └── app.spec.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cargo/config.toml ================================================ [build] ================================================ FILE: .github/FUNDING.yml ================================================ github: cjpais custom: ["https://handy.computer/donate", "https://www.paypal.me/cjpais"] buy_me_a_coffee: cjpais ko_fi: cjpais ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug Report about: Create a report to help us improve Handy title: "[BUG] " labels: ["bug"] assignees: "" --- ## Before You Submit **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. ## Bug Description A clear and concise description of what the bug is. ## System Information **App Version:** **Operating System:** **CPU:** **GPU:** ## Logs ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: ✏️ Post-processing / Editing Transcripts url: https://github.com/cjpais/Handy/discussions/168 about: Looking to edit, format, or post-process transcripts? Join this discussion - name: ⌨️ Keyboard Shortcuts / Hotkeys url: https://github.com/cjpais/Handy/discussions/211 about: Want different keyboard shortcuts or hotkey configurations? Join this discussion - name: 💡 Feature Request or Idea url: https://github.com/cjpais/Handy/discussions about: Please post feature requests and ideas in our Discussions tab - name: 💬 General Discussion url: https://github.com/cjpais/Handy/discussions about: Ask questions and discuss Handy with the community ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ ## Before Submitting This PR **Please confirm you have done the following:** - [ ] 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 - [ ] I have read [CONTRIBUTING.md](https://github.com/cjpais/Handy/blob/main/CONTRIBUTING.md) **If this is a feature or change that was previously closed/rejected:** - [ ] I have explained in the description below why this should be reconsidered - [ ] I have gathered community feedback (link to discussion below) ## Human Written Description ## Related Issues/Discussions Fixes # Discussion: ## Community Feedback ## Testing ## Screenshots/Videos (if applicable) ## AI Assistance - [ ] No AI was used in this PR - [ ] AI was used (please describe below) **If AI was used:** - Tools used: - How extensively: ================================================ FILE: .github/workflows/build-test.yml ================================================ name: "Build Test" on: workflow_dispatch jobs: build-test: permissions: contents: write strategy: fail-fast: false matrix: include: - platform: "macos-26" # for Arm based macs (M1 and above). Uses macOS 26 for Apple Intelligence SDK. args: "--target aarch64-apple-darwin" target: "aarch64-apple-darwin" - platform: "macos-latest" # for Intel based macs. args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - platform: "ubuntu-22.04" # Build .deb on 22.04 args: "--bundles deb" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04" # Build AppImage and RPM on 24.04 args: "--bundles appimage,rpm" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04-arm" # Build for ARM64 Linux args: "--bundles appimage,deb,rpm" target: "aarch64-unknown-linux-gnu" - platform: "windows-latest" args: "" target: "x86_64-pc-windows-msvc" - platform: "windows-11-arm" # for ARM64 Windows runner args: "--target aarch64-pc-windows-msvc" target: "aarch64-pc-windows-msvc" uses: ./.github/workflows/build.yml with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} sign-binaries: true asset-prefix: "handy-test" upload-artifacts: true is-debug-build: ${{ contains(matrix.args, '--debug') }} secrets: inherit ================================================ FILE: .github/workflows/build.yml ================================================ name: "Build" on: workflow_call: inputs: platform: required: true type: string target: required: true type: string build-args: required: false type: string default: "" release-id: required: false type: string asset-prefix: required: false type: string default: "handy" asset-name-pattern: required: false type: string default: "" upload-artifacts: required: false type: boolean default: false sign-binaries: required: false type: boolean default: false repository: required: false type: string ref: required: false type: string default: ${{ github.ref }} is-debug-build: required: false type: boolean default: false env: TSC_VERSION: "0.9.0" jobs: build: permissions: contents: write runs-on: ${{ inputs.platform }} steps: - name: Enable long paths (Windows) if: runner.os == 'Windows' shell: pwsh run: | New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" ` -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force git config --system core.longpaths true - name: Checkout repository uses: actions/checkout@v4 with: repository: ${{ inputs.repository }} ref: ${{ inputs.ref }} fetch-depth: 0 - name: Get version from tauri.conf.json id: get-version shell: bash run: | VERSION=$(grep -o '"version": "[^"]*"' src-tauri/tauri.conf.json | cut -d'"' -f4) echo "Application version from tauri.conf.json: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Determine build profile id: build-profile shell: bash run: | if [[ "${{ inputs.is-debug-build }}" == "true" ]]; then echo "profile=debug" >> "$GITHUB_OUTPUT" echo "Build profile: debug" else echo "profile=release" >> "$GITHUB_OUTPUT" echo "Build profile: release" fi - name: Setup Bun uses: oven-sh/setup-bun@v2 - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: # Those targets are only used on macos runners so it's in an `if` to slightly speed up windows and linux builds. targets: ${{ contains(inputs.platform, 'macos') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }} - name: Rust cache uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" key: ${{ inputs.platform }}-${{ inputs.target }} - name: install dependencies (ubuntu 24.04 x64) if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm') run: | sudo apt-get update 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 \ libwebkit2gtk-4.1-0=2.44.0-2 \ libwebkit2gtk-4.1-dev=2.44.0-2 \ libjavascriptcoregtk-4.1-0=2.44.0-2 \ libjavascriptcoregtk-4.1-dev=2.44.0-2 \ gir1.2-javascriptcoregtk-4.1=2.44.0-2 \ gir1.2-webkit2-4.1=2.44.0-2 - name: install dependencies (ubuntu 24.04 arm64) if: contains(inputs.platform, 'ubuntu-24.04-arm') run: | sudo apt-get update 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 - name: install dependencies (ubuntu 22.04) if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm') run: | sudo apt-get update 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 - name: Verify gtk-layer-shell runtime dependency (Ubuntu) if: contains(inputs.platform, 'ubuntu') run: | dpkg-query -W -f='${Status}\n' libgtk-layer-shell0 | grep -q "install ok installed" ldconfig -p | grep -q "libgtk-layer-shell.so.0" - name: Install Vulkan SDK (Windows x64) if: contains(inputs.platform, 'windows') && !contains(inputs.target, 'aarch64') uses: humbletim/install-vulkan-sdk@v1.2 with: version: 1.4.309.0 cache: true # humbletim/install-vulkan-sdk@v1.2 cannot target Windows ARM64 yet. # Download prebuilt binaries (Bin) + build headers/libs from source. # See https://github.com/humbletim/install-vulkan-sdk/pull/22 for prebuilt ARM64 support progress. - name: Prepare Vulkan SDK env (Windows ARM64) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') shell: pwsh run: | $sdkDir = Join-Path $env:GITHUB_WORKSPACE "VULKAN_SDK" New-Item -ItemType Directory -Force -Path $sdkDir | Out-Null Add-Content -Path $env:GITHUB_ENV -Value "VULKAN_SDK=$sdkDir" - name: Download Vulkan SDK binaries (Windows ARM64) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') shell: pwsh run: | $url = "https://sdk.lunarg.com/sdk/download/1.4.309.0/warm/InstallVulkanARM64-1.4.309.0.exe" $outFile = "vulkan_sdk_arm.exe" Write-Host "Downloading Vulkan SDK binaries from $url" Invoke-WebRequest -Uri $url -OutFile $outFile - name: Extract Vulkan SDK binaries (Windows ARM64) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') shell: pwsh run: | $sdkDir = $env:VULKAN_SDK $sevenZip = (Get-Command 7z.exe -ErrorAction Stop).Source Write-Host "Extracting binaries to $sdkDir" & $sevenZip x "./vulkan_sdk_arm.exe" "-o$sdkDir" -aoa $binPath = Join-Path $sdkDir "Bin" Add-Content -Path $env:GITHUB_PATH -Value $binPath Write-Host "Verifying glslc..." & (Join-Path $binPath "glslc.exe") --version - name: Build Vulkan SDK headers and libs (Windows ARM64) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') uses: humbletim/setup-vulkan-sdk@v1.2.1 with: vulkan-query-version: 1.4.309.0 vulkan-components: Vulkan-Headers, Vulkan-Loader vulkan-use-cache: true - name: Cache trusted-signing-cli if: contains(inputs.platform, 'windows') && inputs.sign-binaries id: cache-tsc uses: actions/cache@v4 with: path: ~/.cargo/bin/trusted-signing-cli* key: trusted-signing-cli-${{ env.TSC_VERSION }}-${{ runner.os }}-${{ runner.arch }} - name: Install trusted-signing-cli if: contains(inputs.platform, 'windows') && inputs.sign-binaries && steps.cache-tsc.outputs.cache-hit != 'true' run: cargo install trusted-signing-cli@${{ env.TSC_VERSION }} - name: Prepare Vulkan SDK for Ubuntu 24.04 if: contains(inputs.platform, 'ubuntu-24.04') && !contains(inputs.platform, 'arm') run: | wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc 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 sudo apt update sudo apt install vulkan-sdk -y sudo apt-get install -y mesa-vulkan-drivers - name: Prepare Vulkan SDK for Ubuntu ARM64 if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm') uses: jakoch/install-vulkan-sdk-action@v1 with: vulkan_version: 1.4.335.0 cache: true - name: Install Vulkan runtime libraries (Ubuntu ARM64) if: contains(inputs.platform, 'ubuntu') && contains(inputs.platform, 'arm') run: | sudo apt-get update sudo apt-get install -y libvulkan-dev mesa-vulkan-drivers - name: Prepare Vulkan SDK for Ubuntu 22.04 if: contains(inputs.platform, 'ubuntu-22.04') && !contains(inputs.platform, 'arm') run: | wget -qO- https://packages.lunarg.com/lunarg-signing-key-pub.asc | sudo tee /etc/apt/trusted.gpg.d/lunarg.asc 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 sudo apt update sudo apt install vulkan-sdk -y sudo apt-get install -y mesa-vulkan-drivers - name: install frontend dependencies if: ${{ !(contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64')) }} run: bun install - name: install frontend dependencies (Windows ARM64) if: ${{ contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') }} run: bun install --cpu=arm64 - name: rustup install target 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') }} run: rustup target add ${{ inputs.target }} - name: import Apple Developer Certificate if: contains(inputs.platform, 'macos') && inputs.sign-binaries env: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12 security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security default-keychain -s build.keychain security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain security set-keychain-settings -t 3600 -u build.keychain security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain security find-identity -v -p codesigning build.keychain - name: verify certificate if: contains(inputs.platform, 'macos') && inputs.sign-binaries run: | CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application") CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}') echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV echo "Certificate imported." - name: Patch asset name pattern id: patch-release-name shell: bash if: ${{ inputs.release-id != '' && inputs.asset-name-pattern != '' }} run: | platform="${{ inputs.platform }}" replacement="$(echo ${platform} | sed -E 's/-latest//')" patched_platform=$(echo '${{ inputs.asset-name-pattern }}' | sed -E "s/\[platform\]/${replacement}/") if [[ -n "${{ inputs.asset-prefix }}" ]]; then patched_platform="${{ inputs.asset-prefix }}_${patched_platform}" fi echo "platform=${patched_platform}" >> $GITHUB_OUTPUT # whisper-rs-sys cmake builds create paths that exceed Windows MAX_PATH # (260 chars). Shorten the target dir to keep paths under the limit. - name: Shorten build path (Windows) if: runner.os == 'Windows' shell: pwsh run: | $drive = Split-Path -Qualifier $env:GITHUB_WORKSPACE $targetDir = "$drive\t" New-Item -ItemType Directory -Force -Path $targetDir | Out-Null echo "CARGO_TARGET_DIR=$targetDir" >> $env:GITHUB_ENV # ggml requires clang for ARM. The VS generator determines the compiler # via the toolset (-T flag) which can't be overridden from env vars. # Use Ninja + clang-cl instead, which respects CMAKE_C_COMPILER. - name: Configure cmake for ARM64 (Windows) if: contains(inputs.platform, 'windows') && contains(inputs.target, 'aarch64') shell: pwsh run: | echo "CMAKE_GENERATOR=Ninja" >> $env:GITHUB_ENV echo "CMAKE_C_COMPILER=clang-cl" >> $env:GITHUB_ENV echo "CMAKE_CXX_COMPILER=clang-cl" >> $env:GITHUB_ENV echo "CMAKE_C_COMPILER_TARGET=aarch64-pc-windows-msvc" >> $env:GITHUB_ENV echo "CMAKE_CXX_COMPILER_TARGET=aarch64-pc-windows-msvc" >> $env:GITHUB_ENV echo "CL=/EHsc" >> $env:GITHUB_ENV - name: Install ONNX Runtime (x86_64 macOS) if: inputs.target == 'x86_64-apple-darwin' shell: bash run: | ORT_VERSION="1.24.2" curl -L -o ort.tgz "https://blob.handy.computer/onnxruntime-osx-x86_64-${ORT_VERSION}.tgz" tar xzf ort.tgz ORT_DIR="$(pwd)/onnxruntime-osx-x86_64-${ORT_VERSION}" echo "ORT_LIB_LOCATION=$ORT_DIR/lib" >> $GITHUB_ENV echo "ORT_PREFER_DYNAMIC_LINK=1" >> $GITHUB_ENV # Bundle the versioned dylib (matches the install name @rpath/libonnxruntime.1.24.2.dylib) jq --arg lib "$ORT_DIR/lib/libonnxruntime.${ORT_VERSION}.dylib" \ '.bundle.macOS.frameworks = [$lib]' \ src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json - name: Install ONNX Runtime (x86_64 Linux, Ubuntu 22.04) if: contains(inputs.platform, 'ubuntu-22.04') && inputs.target == 'x86_64-unknown-linux-gnu' shell: bash run: | ORT_VERSION="1.24.2" curl -L -o ort.tgz "https://blob.handy.computer/onnxruntime-linux-x86_64-${ORT_VERSION}.tgz" tar xzf ort.tgz ORT_DIR="$(pwd)/onnxruntime-linux-x86_64-${ORT_VERSION}" echo "ORT_LIB_LOCATION=$ORT_DIR/lib" >> $GITHUB_ENV echo "ORT_PREFER_DYNAMIC_LINK=1" >> $GITHUB_ENV # Resolve symlinks so the deb bundler gets real files (not broken symlinks) for f in "$ORT_DIR"/lib/libonnxruntime.so*; do if [ -L "$f" ]; then cp -L "$f" "$f.real" && mv "$f.real" "$f" fi done # Add the shared libs to the deb package under /usr/lib # deb.files key = destination in package, value = source on disk jq --arg so "$ORT_DIR/lib/libonnxruntime.so" \ --arg so1 "$ORT_DIR/lib/libonnxruntime.so.1" \ --arg sov "$ORT_DIR/lib/libonnxruntime.so.1.24.2" \ '.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' \ src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json - name: Build with Tauri uses: tauri-apps/tauri-action@v0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} APPLE_ID: ${{ inputs.sign-binaries && secrets.APPLE_ID || '' }} APPLE_ID_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_ID_PASSWORD || '' }} APPLE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_PASSWORD || '' }} APPLE_TEAM_ID: ${{ inputs.sign-binaries && secrets.APPLE_TEAM_ID || '' }} APPLE_CERTIFICATE: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE || '' }} APPLE_CERTIFICATE_PASSWORD: ${{ inputs.sign-binaries && secrets.APPLE_CERTIFICATE_PASSWORD || '' }} APPLE_SIGNING_IDENTITY: ${{ inputs.sign-binaries && env.CERT_ID || '' }} AZURE_CLIENT_ID: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_ID || '' }} AZURE_CLIENT_SECRET: ${{ inputs.sign-binaries && secrets.AZURE_CLIENT_SECRET || '' }} AZURE_TENANT_ID: ${{ inputs.sign-binaries && secrets.AZURE_TENANT_ID || '' }} TAURI_SIGNING_PRIVATE_KEY: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY || '' }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ inputs.sign-binaries && secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD || '' }} WHISPER_NO_AVX: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }} WHISPER_NO_AVX2: ${{ contains(inputs.platform, 'ubuntu') && !contains(inputs.platform, 'arm') && 'ON' || '' }} with: tagName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }} releaseName: ${{ inputs.release-id && format('v{0}', steps.get-version.outputs.version) || '' }} releaseId: ${{ inputs.release-id }} assetNamePattern: ${{ steps.patch-release-name.outputs.platform }} args: ${{ inputs.build-args }} - name: Verify macOS dylib bundling if: inputs.target == 'x86_64-apple-darwin' shell: bash run: | APP=$(find src-tauri/target -name "*.app" -type d | head -1) echo "=== Frameworks contents ===" ls -la "$APP/Contents/Frameworks/" | grep onnx || true echo "=== Binary linked libs ===" otool -L "$APP/Contents/MacOS/handy" | grep onnx || true echo "=== Checking all @rpath deps are satisfied ===" # Extract every @rpath lib the binary needs otool -L "$APP/Contents/MacOS/handy" | grep '@rpath/' | awk '{print $1}' | while read dep; do libname=$(basename "$dep") if [ ! -f "$APP/Contents/Frameworks/$libname" ]; then echo "MISSING: $libname not found in Frameworks/" exit 1 else echo "OK: $libname" fi done - name: Upload artifacts (macOS) if: inputs.upload-artifacts && contains(inputs.platform, 'macos') uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}-${{ inputs.target }} path: | src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/dmg/*.dmg src-tauri/target/${{ inputs.target }}/${{ steps.build-profile.outputs.profile }}/bundle/macos/*.app retention-days: 30 - name: Install FUSE for AppImage processing if: contains(inputs.platform, 'ubuntu') run: | sudo apt-get update sudo apt-get install -y fuse libfuse2 - name: Remove libwayland-client.so from AppImage if: contains(inputs.platform, 'ubuntu') run: | # Find the AppImage file APPIMAGE_PATH=$(find src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage -name "*.AppImage" | head -1) if [ -n "$APPIMAGE_PATH" ]; then echo "Processing AppImage: $APPIMAGE_PATH" # Make AppImage executable chmod +x "$APPIMAGE_PATH" # Extract AppImage cd "$(dirname "$APPIMAGE_PATH")" APPIMAGE_NAME=$(basename "$APPIMAGE_PATH") # Extract using the AppImage itself "./$APPIMAGE_NAME" --appimage-extract # Remove libwayland-client.so files echo "Removing libwayland-client.so files..." find squashfs-root -name "libwayland-client.so*" -type f -delete # List what was removed for verification echo "Files remaining in lib directories:" find squashfs-root -name "lib*" -type d | head -5 | while read dir; do echo "Contents of $dir:" ls "$dir" | grep -E "(wayland|fuse)" || echo " No wayland/fuse libraries found" done # Detect architecture and get appropriate appimagetool if [[ "$(uname -m)" == "aarch64" ]]; then APPIMAGETOOL_ARCH="aarch64" else APPIMAGETOOL_ARCH="x86_64" fi wget -q "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-${APPIMAGETOOL_ARCH}.AppImage" chmod +x "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" # Repackage AppImage with no-appstream to avoid warnings ARCH="${APPIMAGETOOL_ARCH}" "./appimagetool-${APPIMAGETOOL_ARCH}.AppImage" --no-appstream squashfs-root "$APPIMAGE_NAME" # Clean up rm -rf squashfs-root "appimagetool-${APPIMAGETOOL_ARCH}.AppImage" echo "libwayland-client.so removed from AppImage successfully" else echo "No AppImage found to process" fi - name: Upload artifacts (Linux) if: inputs.upload-artifacts && contains(inputs.platform, 'ubuntu') uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}-${{ inputs.platform }}-${{ inputs.target }} path: | src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/deb/*.deb src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/appimage/*.AppImage src-tauri/target/${{ steps.build-profile.outputs.profile }}/bundle/rpm/*.rpm retention-days: 30 - name: Resolve Windows artifact path if: inputs.upload-artifacts && contains(inputs.platform, 'windows') id: win-artifact-path shell: pwsh run: | $base = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { "src-tauri/target" } echo "base=$base" >> $env:GITHUB_OUTPUT - name: Upload artifacts (Windows) if: inputs.upload-artifacts && contains(inputs.platform, 'windows') uses: actions/upload-artifact@v4 with: name: ${{ inputs.asset-prefix }}-${{ inputs.target }} path: | ${{ 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 ${{ 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 retention-days: 30 ================================================ FILE: .github/workflows/code-quality.yml ================================================ name: "code quality" on: workflow_dispatch: push: branches: [main] paths: - "src/**" - "package.json" - "bun.lock" - ".eslintrc*" - "eslint.config.*" - ".prettierrc*" - "tsconfig*" - "tailwind.config.*" - ".github/workflows/**" pull_request: paths: - "src/**" - "package.json" - "bun.lock" - ".eslintrc*" - "eslint.config.*" - ".prettierrc*" - "tsconfig*" - "tailwind.config.*" - ".github/workflows/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: code-quality: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile - name: Check translation consistency run: bun run check:translations - name: Run ESLint run: bun run lint - name: Run prettier run: bun run format:check ================================================ FILE: .github/workflows/main-build.yml ================================================ name: "Main Branch Build" # Runs the full cross-platform build on every push to main so breakage is # caught before a manual release is triggered. Artifacts are kept for 30 days # so any commit on main has a downloadable, testable build. on: push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: permissions: contents: write strategy: fail-fast: false matrix: include: - platform: "macos-26" args: "--target aarch64-apple-darwin" target: "aarch64-apple-darwin" - platform: "macos-latest" args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - platform: "ubuntu-22.04" args: "--bundles deb" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04" args: "--bundles appimage,rpm" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04-arm" args: "--bundles appimage,deb,rpm" target: "aarch64-unknown-linux-gnu" - platform: "windows-latest" args: "" target: "x86_64-pc-windows-msvc" - platform: "windows-11-arm" args: "--target aarch64-pc-windows-msvc" target: "aarch64-pc-windows-msvc" uses: ./.github/workflows/build.yml with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} upload-artifacts: true sign-binaries: true secrets: inherit ================================================ FILE: .github/workflows/nix-check.yml ================================================ # Nix CI — two tiers: # # 1. Quick checks (bun.nix sync, flake eval) run on ANY source change # so compilation-breaking edits are caught by flake eval. # 2. Full nix build (~25 min) only runs when nix packaging files change. # # Setting up a Cachix binary cache would further reduce full-build times. name: "nix build check" on: workflow_dispatch: push: branches: [main] paths: - "flake.nix" - "flake.lock" - ".nix/**" - "bun.lock" - "src-tauri/**" - "src/**" - ".github/workflows/**" pull_request: paths: - "flake.nix" - "flake.lock" - ".nix/**" - "bun.lock" - "src-tauri/**" - "src/**" - ".github/workflows/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: nix-build: runs-on: ubuntu-24.04 continue-on-error: false steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - uses: cachix/install-nix-action@v30 with: nix_path: nixpkgs=channel:nixos-unstable - uses: DeterminateSystems/magic-nix-cache-action@565684385bcd71bad329742eefe8d12f2e765b39 # v13 # Regenerate .nix/bun.nix from bun.lock and check if it matches # what's committed. A diff means the developer forgot to run # bun scripts/check-nix-deps.ts or bun install (which triggers it). - name: Check bun.nix is up to date id: bun-check run: | bunx bun2nix -o .nix/bun.nix if ! git diff --quiet .nix/bun.nix; then echo "outdated=true" >> "$GITHUB_OUTPUT" fi - name: Hint on outdated bun.nix if: steps.bun-check.outputs.outdated == 'true' run: | echo "" echo "::warning::.nix/bun.nix is out of sync with bun.lock" echo "" echo "┌──────────────────────────────────────────────────────────────┐" echo "│ .nix/bun.nix is outdated. To fix, run: │" echo "│ │" echo "│ bun scripts/check-nix-deps.ts │" echo "│ │" echo "│ Or simply run 'bun install' — the postinstall hook will │" echo "│ regenerate it automatically. Commit the resulting changes. │" echo "└──────────────────────────────────────────────────────────────┘" echo "" echo "Diff:" git diff .nix/bun.nix exit 1 # Evaluate the flake to catch issues with cargo git dependency hashes, # missing inputs, or other Nix expression errors. # Skip if bun.nix is already outdated — nix eval would fail with a # cryptic error, and the bun-check step already printed a clear message. - name: Check flake evaluation if: steps.bun-check.outputs.outdated != 'true' id: eval run: | if ! nix eval .#packages.x86_64-linux.handy.drvPath 2>eval_err.log; then echo "failed=true" >> "$GITHUB_OUTPUT" cat eval_err.log fi - name: Hint on evaluation failure if: steps.eval.outputs.failed == 'true' run: | echo "" echo "::warning::flake.nix evaluation failed" echo "" cat eval_err.log exit 1 # Detect whether nix packaging files changed — if only source code # changed, the quick checks above are sufficient. # Skipped on workflow_dispatch (no base ref) — full build runs instead. - name: Check if nix files changed if: github.event_name == 'pull_request' id: nix-files run: | git fetch origin ${{ github.base_ref }} --depth=1 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 echo "changed=true" >> "$GITHUB_OUTPUT" fi # Full build — catches runtime build errors (broken dependencies, # sandbox issues, compilation failures) that flake eval alone misses. # On PRs: only runs when nix packaging files change (~25 min with cold cache). # On push to main and workflow_dispatch: always runs so every commit on # main has a verified nix build before release. - name: Build handy 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') run: nix build .#handy -L --show-trace ================================================ FILE: .github/workflows/playwright.yml ================================================ name: "Playwright" on: workflow_dispatch: pull_request: paths: - "src/**" - "package.json" - "bun.lock" - "playwright.config.*" - "tests/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: playwright: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: latest - name: Install dependencies run: bun install --frozen-lockfile - name: Install Playwright browsers run: bunx playwright install chromium - name: Run Playwright tests run: bun run test:playwright - name: Upload test results if: failure() uses: actions/upload-artifact@v4 with: name: playwright-report path: playwright-report/ retention-days: 7 ================================================ FILE: .github/workflows/pr-test-build.yml ================================================ name: "PR Test Build" on: workflow_dispatch: inputs: pr_number: description: "PR number to build" required: true type: string jobs: build-test: permissions: contents: write strategy: fail-fast: false matrix: include: - platform: "macos-26" args: "--target aarch64-apple-darwin" target: "aarch64-apple-darwin" - platform: "macos-latest" args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - platform: "ubuntu-22.04" args: "--bundles deb" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04" args: "--bundles appimage,rpm" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04-arm" # Build for ARM64 Linux args: "--bundles appimage,deb,rpm" target: "aarch64-unknown-linux-gnu" - platform: "windows-latest" args: "" target: "x86_64-pc-windows-msvc" - platform: "windows-11-arm" args: "--target aarch64-pc-windows-msvc" target: "aarch64-pc-windows-msvc" uses: ./.github/workflows/build.yml with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} sign-binaries: true asset-prefix: "handy-pr-${{ inputs.pr_number }}" upload-artifacts: true is-debug-build: ${{ contains(matrix.args, '--debug') }} ref: ${{ format('refs/pull/{0}/merge', inputs.pr_number) }} secrets: inherit comment-on-pr: needs: build-test runs-on: ubuntu-latest permissions: pull-requests: write steps: - name: Post artifact links to PR uses: actions/github-script@v7 with: script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.createComment({ owner: context.repo.owner, repo: context.repo.repo, issue_number: ${{ inputs.pr_number }}, 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.` }); ================================================ FILE: .github/workflows/release.yml ================================================ name: "Release" on: workflow_dispatch jobs: create-release: permissions: contents: write runs-on: ubuntu-latest outputs: release-id: ${{ steps.create-release.outputs.result }} version: ${{ steps.get-version.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v4 - name: Get version from tauri.conf.json id: get-version shell: bash run: | VERSION=$(grep -o '"version": "[^"]*"' src-tauri/tauri.conf.json | cut -d'"' -f4) echo "Application version from tauri.conf.json: $VERSION" echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Create Draft Release id: create-release uses: actions/github-script@v7 with: script: | const { data } = await github.rest.repos.createRelease({ owner: context.repo.owner, repo: context.repo.repo, tag_name: `v${{ steps.get-version.outputs.version }}`, name: `v${{ steps.get-version.outputs.version }}`, draft: true, prerelease: false, generate_release_notes: true }) return data.id publish-tauri: permissions: contents: write needs: create-release strategy: fail-fast: false matrix: include: - platform: "macos-26" # for Arm based macs (M1 and above). Uses macOS 26 for Apple Intelligence SDK. args: "--target aarch64-apple-darwin" target: "aarch64-apple-darwin" - platform: "macos-latest" # for Intel based macs. args: "--target x86_64-apple-darwin" target: "x86_64-apple-darwin" - platform: "ubuntu-22.04" # Build .deb on 22.04 args: "--bundles deb" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04" # Build AppImage and RPM on 24.04 args: "--bundles appimage,rpm" target: "x86_64-unknown-linux-gnu" - platform: "ubuntu-24.04-arm" # Build for ARM64 Linux args: "--bundles appimage,deb,rpm" target: "aarch64-unknown-linux-gnu" - platform: "windows-latest" args: "" target: "x86_64-pc-windows-msvc" - platform: "windows-11-arm" # for ARM64 Windows runner args: "--target aarch64-pc-windows-msvc" target: "aarch64-pc-windows-msvc" uses: ./.github/workflows/build.yml with: platform: ${{ matrix.platform }} target: ${{ matrix.target }} build-args: ${{ matrix.args }} sign-binaries: true asset-prefix: "handy" upload-artifacts: false release-id: ${{ needs.create-release.outputs.release-id }} secrets: inherit ================================================ FILE: .github/workflows/test.yml ================================================ name: "test" on: workflow_dispatch: push: branches: [main] paths: - "src-tauri/**" pull_request: paths: - "src-tauri/**" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: rust-tests: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v4 - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libasound2-dev libssl-dev libgtk-layer-shell-dev - uses: swatinem/rust-cache@v2 with: workspaces: "./src-tauri -> target" - name: Use mock TranscriptionManager (CI only) working-directory: src-tauri run: | # Swap to mock adapter - avoids compiling whisper/Vulkan cp src/managers/transcription_mock.rs src/managers/transcription.rs sed -i '/^transcribe-rs/d' Cargo.toml - name: Run Rust tests working-directory: src-tauri run: cargo test ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* package-lock.json node_modules dist dist-ssr *.local *.local.* # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? /target/ recording_* .crush/ # Playwright playwright-report/ test-results/ blob-report/ .direnv .envrc # Nix build output result ================================================ FILE: .nix/bun-lock-hash ================================================ e00b12c719a762004194cec01f2ad0b78ae483c41452bcca8537179d28e704b1 ================================================ FILE: .nix/bun.nix ================================================ # Autogenerated by `bun2nix`, editing manually is not recommended # # Set of Bun packages to install # # Consume this with `fetchBunDeps` (recommended) # or `pkgs.callPackage` if you wish to handle # it manually. { copyPathToStore, fetchFromGitHub, fetchgit, fetchurl, ... }: { "@babel/code-frame@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz"; hash = "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="; }; "@babel/compat-data@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz"; hash = "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="; }; "@babel/core@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz"; hash = "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="; }; "@babel/generator@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz"; hash = "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="; }; "@babel/helper-compilation-targets@7.27.2" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz"; hash = "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="; }; "@babel/helper-globals@7.28.0" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz"; hash = "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="; }; "@babel/helper-module-imports@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz"; hash = "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="; }; "@babel/helper-module-transforms@7.28.3" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz"; hash = "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="; }; "@babel/helper-plugin-utils@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz"; hash = "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="; }; "@babel/helper-string-parser@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"; hash = "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="; }; "@babel/helper-validator-identifier@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"; hash = "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="; }; "@babel/helper-validator-option@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz"; hash = "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="; }; "@babel/helpers@7.28.4" = fetchurl { url = "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz"; hash = "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="; }; "@babel/parser@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz"; hash = "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="; }; "@babel/plugin-transform-react-jsx-self@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz"; hash = "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="; }; "@babel/plugin-transform-react-jsx-source@7.27.1" = fetchurl { url = "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz"; hash = "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="; }; "@babel/runtime@7.28.4" = fetchurl { url = "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz"; hash = "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="; }; "@babel/template@7.27.2" = fetchurl { url = "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz"; hash = "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="; }; "@babel/traverse@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz"; hash = "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="; }; "@babel/types@7.28.5" = fetchurl { url = "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz"; hash = "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="; }; "@emnapi/core@1.6.0" = fetchurl { url = "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz"; hash = "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="; }; "@emnapi/runtime@1.6.0" = fetchurl { url = "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.6.0.tgz"; hash = "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="; }; "@emnapi/wasi-threads@1.1.0" = fetchurl { url = "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz"; hash = "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="; }; "@emotion/babel-plugin@11.13.5" = fetchurl { url = "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz"; hash = "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="; }; "@emotion/cache@11.14.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz"; hash = "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="; }; "@emotion/hash@0.9.2" = fetchurl { url = "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz"; hash = "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="; }; "@emotion/memoize@0.9.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz"; hash = "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ=="; }; "@emotion/react@11.14.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz"; hash = "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA=="; }; "@emotion/serialize@1.3.3" = fetchurl { url = "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.3.tgz"; hash = "sha512-EISGqt7sSNWHGI76hC7x1CksiXPahbxEOrC5RjmFRJTqLyEK9/9hZvBbiYn70dw4wuwMKiEMCUlR6ZXTSWQqxA=="; }; "@emotion/sheet@1.4.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz"; hash = "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg=="; }; "@emotion/unitless@0.10.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz"; hash = "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="; }; "@emotion/use-insertion-effect-with-fallbacks@1.2.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.2.0.tgz"; hash = "sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg=="; }; "@emotion/utils@1.4.2" = fetchurl { url = "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.2.tgz"; hash = "sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA=="; }; "@emotion/weak-memoize@0.4.0" = fetchurl { url = "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz"; hash = "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="; }; "@esbuild/aix-ppc64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz"; hash = "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="; }; "@esbuild/android-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz"; hash = "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ=="; }; "@esbuild/android-arm@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz"; hash = "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="; }; "@esbuild/android-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz"; hash = "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g=="; }; "@esbuild/darwin-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz"; hash = "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w=="; }; "@esbuild/darwin-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz"; hash = "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ=="; }; "@esbuild/freebsd-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz"; hash = "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA=="; }; "@esbuild/freebsd-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz"; hash = "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw=="; }; "@esbuild/linux-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz"; hash = "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA=="; }; "@esbuild/linux-arm@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz"; hash = "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw=="; }; "@esbuild/linux-ia32@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz"; hash = "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw=="; }; "@esbuild/linux-loong64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz"; hash = "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw=="; }; "@esbuild/linux-mips64el@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz"; hash = "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ=="; }; "@esbuild/linux-ppc64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz"; hash = "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw=="; }; "@esbuild/linux-riscv64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz"; hash = "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww=="; }; "@esbuild/linux-s390x@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz"; hash = "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw=="; }; "@esbuild/linux-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz"; hash = "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ=="; }; "@esbuild/netbsd-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz"; hash = "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg=="; }; "@esbuild/netbsd-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz"; hash = "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A=="; }; "@esbuild/openbsd-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz"; hash = "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg=="; }; "@esbuild/openbsd-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz"; hash = "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw=="; }; "@esbuild/openharmony-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz"; hash = "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ=="; }; "@esbuild/sunos-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz"; hash = "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA=="; }; "@esbuild/win32-arm64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz"; hash = "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q=="; }; "@esbuild/win32-ia32@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz"; hash = "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA=="; }; "@esbuild/win32-x64@0.25.11" = fetchurl { url = "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz"; hash = "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA=="; }; "@eslint-community/eslint-utils@4.9.0" = fetchurl { url = "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz"; hash = "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g=="; }; "@eslint-community/regexpp@4.12.2" = fetchurl { url = "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz"; hash = "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="; }; "@eslint/config-array@0.21.1" = fetchurl { url = "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz"; hash = "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="; }; "@eslint/config-helpers@0.4.2" = fetchurl { url = "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz"; hash = "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="; }; "@eslint/core@0.17.0" = fetchurl { url = "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz"; hash = "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="; }; "@eslint/eslintrc@3.3.3" = fetchurl { url = "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz"; hash = "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="; }; "@eslint/js@9.39.1" = fetchurl { url = "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz"; hash = "sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw=="; }; "@eslint/object-schema@2.1.7" = fetchurl { url = "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz"; hash = "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="; }; "@eslint/plugin-kit@0.4.1" = fetchurl { url = "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz"; hash = "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="; }; "@floating-ui/core@1.7.3" = fetchurl { url = "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz"; hash = "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="; }; "@floating-ui/dom@1.7.4" = fetchurl { url = "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz"; hash = "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="; }; "@floating-ui/utils@0.2.10" = fetchurl { url = "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz"; hash = "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="; }; "@humanfs/core@0.19.1" = fetchurl { url = "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz"; hash = "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="; }; "@humanfs/node@0.16.7" = fetchurl { url = "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz"; hash = "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="; }; "@humanwhocodes/module-importer@1.0.1" = fetchurl { url = "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz"; hash = "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="; }; "@humanwhocodes/retry@0.4.3" = fetchurl { url = "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz"; hash = "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="; }; "@jridgewell/gen-mapping@0.3.13" = fetchurl { url = "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz"; hash = "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="; }; "@jridgewell/remapping@2.3.5" = fetchurl { url = "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz"; hash = "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="; }; "@jridgewell/resolve-uri@3.1.2" = fetchurl { url = "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz"; hash = "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="; }; "@jridgewell/sourcemap-codec@1.5.5" = fetchurl { url = "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz"; hash = "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="; }; "@jridgewell/trace-mapping@0.3.31" = fetchurl { url = "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz"; hash = "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="; }; "@napi-rs/wasm-runtime@1.0.7" = fetchurl { url = "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz"; hash = "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw=="; }; "@playwright/test@1.58.0" = fetchurl { url = "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz"; hash = "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg=="; }; "@rolldown/pluginutils@1.0.0-beta.27" = fetchurl { url = "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz"; hash = "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="; }; "@rollup/rollup-android-arm-eabi@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz"; hash = "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="; }; "@rollup/rollup-android-arm64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz"; hash = "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="; }; "@rollup/rollup-darwin-arm64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz"; hash = "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA=="; }; "@rollup/rollup-darwin-x64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz"; hash = "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA=="; }; "@rollup/rollup-freebsd-arm64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz"; hash = "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA=="; }; "@rollup/rollup-freebsd-x64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz"; hash = "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ=="; }; "@rollup/rollup-linux-arm-gnueabihf@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz"; hash = "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ=="; }; "@rollup/rollup-linux-arm-musleabihf@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz"; hash = "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ=="; }; "@rollup/rollup-linux-arm64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz"; hash = "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg=="; }; "@rollup/rollup-linux-arm64-musl@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz"; hash = "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q=="; }; "@rollup/rollup-linux-loong64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz"; hash = "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA=="; }; "@rollup/rollup-linux-ppc64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz"; hash = "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw=="; }; "@rollup/rollup-linux-riscv64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz"; hash = "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw=="; }; "@rollup/rollup-linux-riscv64-musl@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz"; hash = "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg=="; }; "@rollup/rollup-linux-s390x-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz"; hash = "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ=="; }; "@rollup/rollup-linux-x64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz"; hash = "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q=="; }; "@rollup/rollup-linux-x64-musl@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz"; hash = "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg=="; }; "@rollup/rollup-openharmony-arm64@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz"; hash = "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw=="; }; "@rollup/rollup-win32-arm64-msvc@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz"; hash = "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w=="; }; "@rollup/rollup-win32-ia32-msvc@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz"; hash = "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg=="; }; "@rollup/rollup-win32-x64-gnu@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz"; hash = "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ=="; }; "@rollup/rollup-win32-x64-msvc@4.52.5" = fetchurl { url = "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz"; hash = "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg=="; }; "@tailwindcss/node@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz"; hash = "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw=="; }; "@tailwindcss/oxide-android-arm64@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz"; hash = "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA=="; }; "@tailwindcss/oxide-darwin-arm64@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz"; hash = "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA=="; }; "@tailwindcss/oxide-darwin-x64@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz"; hash = "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg=="; }; "@tailwindcss/oxide-freebsd-x64@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz"; hash = "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg=="; }; "@tailwindcss/oxide-linux-arm-gnueabihf@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz"; hash = "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw=="; }; "@tailwindcss/oxide-linux-arm64-gnu@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz"; hash = "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w=="; }; "@tailwindcss/oxide-linux-arm64-musl@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz"; hash = "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ=="; }; "@tailwindcss/oxide-linux-x64-gnu@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz"; hash = "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew=="; }; "@tailwindcss/oxide-linux-x64-musl@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz"; hash = "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw=="; }; "@tailwindcss/oxide-wasm32-wasi@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz"; hash = "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q=="; }; "@tailwindcss/oxide-win32-arm64-msvc@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz"; hash = "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A=="; }; "@tailwindcss/oxide-win32-x64-msvc@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz"; hash = "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg=="; }; "@tailwindcss/oxide@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz"; hash = "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg=="; }; "@tailwindcss/vite@4.1.16" = fetchurl { url = "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz"; hash = "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg=="; }; "@tauri-apps/api@2.10.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz"; hash = "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="; }; "@tauri-apps/api@2.9.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.0.tgz"; hash = "sha512-qD5tMjh7utwBk9/5PrTA/aGr3i5QaJ/Mlt7p8NilQ45WgbifUNPyKWsA63iQ8YfQq6R8ajMapU+/Q8nMcPRLNw=="; }; "@tauri-apps/cli-darwin-arm64@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz"; hash = "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="; }; "@tauri-apps/cli-darwin-x64@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz"; hash = "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="; }; "@tauri-apps/cli-linux-arm-gnueabihf@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz"; hash = "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="; }; "@tauri-apps/cli-linux-arm64-gnu@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz"; hash = "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="; }; "@tauri-apps/cli-linux-arm64-musl@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz"; hash = "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="; }; "@tauri-apps/cli-linux-riscv64-gnu@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz"; hash = "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="; }; "@tauri-apps/cli-linux-x64-gnu@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz"; hash = "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="; }; "@tauri-apps/cli-linux-x64-musl@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz"; hash = "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="; }; "@tauri-apps/cli-win32-arm64-msvc@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz"; hash = "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="; }; "@tauri-apps/cli-win32-ia32-msvc@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz"; hash = "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="; }; "@tauri-apps/cli-win32-x64-msvc@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz"; hash = "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="; }; "@tauri-apps/cli@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz"; hash = "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="; }; "@tauri-apps/plugin-autostart@2.5.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-autostart/-/plugin-autostart-2.5.1.tgz"; hash = "sha512-zS/xx7yzveCcotkA+8TqkI2lysmG2wvQXv2HGAVExITmnFfHAdj1arGsbbfs3o6EktRHf6l34pJxc3YGG2mg7w=="; }; "@tauri-apps/plugin-clipboard-manager@2.3.2" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz"; hash = "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="; }; "@tauri-apps/plugin-dialog@2.6.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz"; hash = "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="; }; "@tauri-apps/plugin-fs@2.4.4" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.4.tgz"; hash = "sha512-MTorXxIRmOnOPT1jZ3w96vjSuScER38ryXY88vl5F0uiKdnvTKKTtaEjTEo8uPbl4e3gnUtfsDVwC7h77GQLvQ=="; }; "@tauri-apps/plugin-global-shortcut@2.3.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-global-shortcut/-/plugin-global-shortcut-2.3.1.tgz"; hash = "sha512-vr40W2N6G63dmBPaha1TsBQLLURXG538RQbH5vAm0G/ovVZyXJrmZR1HF1W+WneNloQvwn4dm8xzwpEXRW560g=="; }; "@tauri-apps/plugin-opener@2.5.2" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.2.tgz"; hash = "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="; }; "@tauri-apps/plugin-os@2.3.2" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz"; hash = "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="; }; "@tauri-apps/plugin-process@2.3.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-process/-/plugin-process-2.3.1.tgz"; hash = "sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA=="; }; "@tauri-apps/plugin-sql@2.3.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-sql/-/plugin-sql-2.3.1.tgz"; hash = "sha512-iNgHnFIR+jRkx9INKVKepzMlxXtNkJUaWuhagFjT4dOttPaNyRnVHgwTjpqZhyVjiklDh2UdEPAJkQKiCPAekw=="; }; "@tauri-apps/plugin-store@2.4.1" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz"; hash = "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA=="; }; "@tauri-apps/plugin-updater@2.10.0" = fetchurl { url = "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.0.tgz"; hash = "sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ=="; }; "@tybys/wasm-util@0.10.1" = fetchurl { url = "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz"; hash = "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="; }; "@types/babel__core@7.20.5" = fetchurl { url = "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz"; hash = "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="; }; "@types/babel__generator@7.27.0" = fetchurl { url = "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz"; hash = "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="; }; "@types/babel__template@7.4.4" = fetchurl { url = "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz"; hash = "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="; }; "@types/babel__traverse@7.28.0" = fetchurl { url = "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz"; hash = "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="; }; "@types/estree@1.0.8" = fetchurl { url = "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"; hash = "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="; }; "@types/json-schema@7.0.15" = fetchurl { url = "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz"; hash = "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="; }; "@types/node@24.9.1" = fetchurl { url = "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz"; hash = "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="; }; "@types/parse-json@4.0.2" = fetchurl { url = "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz"; hash = "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw=="; }; "@types/prop-types@15.7.15" = fetchurl { url = "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz"; hash = "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="; }; "@types/react-dom@18.3.7" = fetchurl { url = "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz"; hash = "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="; }; "@types/react-select@5.0.1" = fetchurl { url = "https://registry.npmjs.org/@types/react-select/-/react-select-5.0.1.tgz"; hash = "sha512-h5Im0AP0dr4AVeHtrcvQrLV+gmPa7SA0AGdxl2jOhtwiE6KgXBFSogWw8az32/nusE6AQHlCOHQWjP1S/+oMWA=="; }; "@types/react-transition-group@4.4.12" = fetchurl { url = "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz"; hash = "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w=="; }; "@types/react@18.3.26" = fetchurl { url = "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz"; hash = "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA=="; }; "@typescript-eslint/eslint-plugin@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz"; hash = "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A=="; }; "@typescript-eslint/parser@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz"; hash = "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA=="; }; "@typescript-eslint/project-service@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz"; hash = "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g=="; }; "@typescript-eslint/scope-manager@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz"; hash = "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg=="; }; "@typescript-eslint/tsconfig-utils@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz"; hash = "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA=="; }; "@typescript-eslint/type-utils@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz"; hash = "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg=="; }; "@typescript-eslint/types@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz"; hash = "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ=="; }; "@typescript-eslint/typescript-estree@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz"; hash = "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA=="; }; "@typescript-eslint/utils@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz"; hash = "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA=="; }; "@typescript-eslint/visitor-keys@8.49.0" = fetchurl { url = "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz"; hash = "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA=="; }; "@vitejs/plugin-react@4.7.0" = fetchurl { url = "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz"; hash = "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="; }; "acorn-jsx@5.3.2" = fetchurl { url = "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"; hash = "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="; }; "acorn@8.15.0" = fetchurl { url = "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz"; hash = "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="; }; "ajv@6.12.6" = fetchurl { url = "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz"; hash = "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="; }; "ansi-styles@4.3.0" = fetchurl { url = "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz"; hash = "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="; }; "argparse@2.0.1" = fetchurl { url = "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz"; hash = "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="; }; "babel-plugin-macros@3.1.0" = fetchurl { url = "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz"; hash = "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="; }; "balanced-match@1.0.2" = fetchurl { url = "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz"; hash = "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="; }; "baseline-browser-mapping@2.8.20" = fetchurl { url = "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz"; hash = "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ=="; }; "brace-expansion@1.1.12" = fetchurl { url = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz"; hash = "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="; }; "brace-expansion@2.0.2" = fetchurl { url = "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz"; hash = "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="; }; "browserslist@4.27.0" = fetchurl { url = "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz"; hash = "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="; }; "callsites@3.1.0" = fetchurl { url = "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz"; hash = "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="; }; "caniuse-lite@1.0.30001751" = fetchurl { url = "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz"; hash = "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw=="; }; "chalk@4.1.2" = fetchurl { url = "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz"; hash = "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="; }; "color-convert@2.0.1" = fetchurl { url = "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz"; hash = "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="; }; "color-name@1.1.4" = fetchurl { url = "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz"; hash = "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="; }; "concat-map@0.0.1" = fetchurl { url = "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz"; hash = "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="; }; "convert-source-map@1.9.0" = fetchurl { url = "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz"; hash = "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="; }; "convert-source-map@2.0.0" = fetchurl { url = "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz"; hash = "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="; }; "cosmiconfig@7.1.0" = fetchurl { url = "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz"; hash = "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="; }; "cross-spawn@7.0.6" = fetchurl { url = "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz"; hash = "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="; }; "csstype@3.1.3" = fetchurl { url = "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz"; hash = "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="; }; "debug@4.4.3" = fetchurl { url = "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz"; hash = "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="; }; "deep-is@0.1.4" = fetchurl { url = "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz"; hash = "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="; }; "detect-libc@2.1.2" = fetchurl { url = "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz"; hash = "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="; }; "dom-helpers@5.2.1" = fetchurl { url = "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz"; hash = "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="; }; "electron-to-chromium@1.5.240" = fetchurl { url = "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.240.tgz"; hash = "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ=="; }; "enhanced-resolve@5.18.3" = fetchurl { url = "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz"; hash = "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="; }; "error-ex@1.3.4" = fetchurl { url = "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz"; hash = "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="; }; "esbuild@0.25.11" = fetchurl { url = "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz"; hash = "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q=="; }; "escalade@3.2.0" = fetchurl { url = "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz"; hash = "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="; }; "escape-string-regexp@4.0.0" = fetchurl { url = "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz"; hash = "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="; }; "eslint-plugin-i18next@6.1.3" = fetchurl { url = "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz"; hash = "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw=="; }; "eslint-scope@8.4.0" = fetchurl { url = "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz"; hash = "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="; }; "eslint-visitor-keys@3.4.3" = fetchurl { url = "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz"; hash = "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="; }; "eslint-visitor-keys@4.2.1" = fetchurl { url = "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz"; hash = "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="; }; "eslint@9.39.1" = fetchurl { url = "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz"; hash = "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g=="; }; "espree@10.4.0" = fetchurl { url = "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz"; hash = "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="; }; "esquery@1.6.0" = fetchurl { url = "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz"; hash = "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="; }; "esrecurse@4.3.0" = fetchurl { url = "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz"; hash = "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="; }; "estraverse@5.3.0" = fetchurl { url = "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz"; hash = "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="; }; "esutils@2.0.3" = fetchurl { url = "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz"; hash = "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="; }; "fast-deep-equal@3.1.3" = fetchurl { url = "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz"; hash = "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="; }; "fast-json-stable-stringify@2.1.0" = fetchurl { url = "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz"; hash = "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="; }; "fast-levenshtein@2.0.6" = fetchurl { url = "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz"; hash = "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="; }; "fdir@6.5.0" = fetchurl { url = "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz"; hash = "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="; }; "file-entry-cache@8.0.0" = fetchurl { url = "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz"; hash = "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="; }; "find-root@1.1.0" = fetchurl { url = "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz"; hash = "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="; }; "find-up@5.0.0" = fetchurl { url = "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz"; hash = "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="; }; "flat-cache@4.0.1" = fetchurl { url = "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz"; hash = "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="; }; "flatted@3.3.3" = fetchurl { url = "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz"; hash = "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="; }; "fsevents@2.3.2" = fetchurl { url = "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz"; hash = "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="; }; "fsevents@2.3.3" = fetchurl { url = "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"; hash = "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="; }; "function-bind@1.1.2" = fetchurl { url = "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"; hash = "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="; }; "gensync@1.0.0-beta.2" = fetchurl { url = "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"; hash = "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="; }; "glob-parent@6.0.2" = fetchurl { url = "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz"; hash = "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="; }; "globals@14.0.0" = fetchurl { url = "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz"; hash = "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="; }; "graceful-fs@4.2.11" = fetchurl { url = "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz"; hash = "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="; }; "has-flag@4.0.0" = fetchurl { url = "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz"; hash = "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="; }; "hasown@2.0.2" = fetchurl { url = "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz"; hash = "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="; }; "hoist-non-react-statics@3.3.2" = fetchurl { url = "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz"; hash = "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="; }; "html-parse-stringify@3.0.1" = fetchurl { url = "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz"; hash = "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="; }; "i18next@25.7.2" = fetchurl { url = "https://registry.npmjs.org/i18next/-/i18next-25.7.2.tgz"; hash = "sha512-58b4kmLpLv1buWUEwegMDUqZVR5J+rT+WTRFaBGL7lxDuJQQ0NrJFrq+eT2N94aYVR1k1Sr13QITNOL88tZCuw=="; }; "ignore@5.3.2" = fetchurl { url = "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz"; hash = "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="; }; "ignore@7.0.5" = fetchurl { url = "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz"; hash = "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="; }; "immer@11.1.3" = fetchurl { url = "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz"; hash = "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="; }; "import-fresh@3.3.1" = fetchurl { url = "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz"; hash = "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="; }; "imurmurhash@0.1.4" = fetchurl { url = "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz"; hash = "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="; }; "is-arrayish@0.2.1" = fetchurl { url = "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz"; hash = "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="; }; "is-core-module@2.16.1" = fetchurl { url = "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz"; hash = "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="; }; "is-extglob@2.1.1" = fetchurl { url = "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"; hash = "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="; }; "is-glob@4.0.3" = fetchurl { url = "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz"; hash = "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="; }; "isexe@2.0.0" = fetchurl { url = "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz"; hash = "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="; }; "jiti@2.6.1" = fetchurl { url = "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz"; hash = "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="; }; "js-tokens@4.0.0" = fetchurl { url = "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz"; hash = "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="; }; "js-yaml@4.1.1" = fetchurl { url = "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz"; hash = "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="; }; "jsesc@3.1.0" = fetchurl { url = "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz"; hash = "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="; }; "json-buffer@3.0.1" = fetchurl { url = "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz"; hash = "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="; }; "json-parse-even-better-errors@2.3.1" = fetchurl { url = "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz"; hash = "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="; }; "json-schema-traverse@0.4.1" = fetchurl { url = "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz"; hash = "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="; }; "json-stable-stringify-without-jsonify@1.0.1" = fetchurl { url = "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz"; hash = "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="; }; "json5@2.2.3" = fetchurl { url = "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"; hash = "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="; }; "keyv@4.5.4" = fetchurl { url = "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz"; hash = "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="; }; "levn@0.4.1" = fetchurl { url = "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"; hash = "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="; }; "lightningcss-android-arm64@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz"; hash = "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="; }; "lightningcss-darwin-arm64@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz"; hash = "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="; }; "lightningcss-darwin-x64@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz"; hash = "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="; }; "lightningcss-freebsd-x64@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz"; hash = "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="; }; "lightningcss-linux-arm-gnueabihf@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz"; hash = "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="; }; "lightningcss-linux-arm64-gnu@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz"; hash = "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="; }; "lightningcss-linux-arm64-musl@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz"; hash = "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="; }; "lightningcss-linux-x64-gnu@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz"; hash = "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="; }; "lightningcss-linux-x64-musl@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz"; hash = "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="; }; "lightningcss-win32-arm64-msvc@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz"; hash = "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="; }; "lightningcss-win32-x64-msvc@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz"; hash = "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="; }; "lightningcss@1.30.2" = fetchurl { url = "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz"; hash = "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="; }; "lines-and-columns@1.2.4" = fetchurl { url = "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz"; hash = "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="; }; "locate-path@6.0.0" = fetchurl { url = "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz"; hash = "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="; }; "lodash.merge@4.6.2" = fetchurl { url = "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz"; hash = "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="; }; "lodash@4.17.21" = fetchurl { url = "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz"; hash = "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="; }; "loose-envify@1.4.0" = fetchurl { url = "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz"; hash = "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="; }; "lru-cache@5.1.1" = fetchurl { url = "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz"; hash = "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="; }; "lucide-react@0.542.0" = fetchurl { url = "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz"; hash = "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw=="; }; "magic-string@0.30.21" = fetchurl { url = "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz"; hash = "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="; }; "memoize-one@6.0.0" = fetchurl { url = "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz"; hash = "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw=="; }; "minimatch@3.1.2" = fetchurl { url = "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz"; hash = "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="; }; "minimatch@9.0.5" = fetchurl { url = "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz"; hash = "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="; }; "ms@2.1.3" = fetchurl { url = "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"; hash = "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="; }; "nanoid@3.3.11" = fetchurl { url = "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz"; hash = "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="; }; "natural-compare@1.4.0" = fetchurl { url = "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"; hash = "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="; }; "node-releases@2.0.26" = fetchurl { url = "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz"; hash = "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="; }; "object-assign@4.1.1" = fetchurl { url = "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz"; hash = "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="; }; "optionator@0.9.4" = fetchurl { url = "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz"; hash = "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="; }; "p-limit@3.1.0" = fetchurl { url = "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"; hash = "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="; }; "p-locate@5.0.0" = fetchurl { url = "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz"; hash = "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="; }; "parent-module@1.0.1" = fetchurl { url = "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz"; hash = "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="; }; "parse-json@5.2.0" = fetchurl { url = "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz"; hash = "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="; }; "path-exists@4.0.0" = fetchurl { url = "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz"; hash = "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="; }; "path-key@3.1.1" = fetchurl { url = "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz"; hash = "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="; }; "path-parse@1.0.7" = fetchurl { url = "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"; hash = "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="; }; "path-type@4.0.0" = fetchurl { url = "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz"; hash = "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="; }; "picocolors@1.1.1" = fetchurl { url = "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz"; hash = "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="; }; "picomatch@4.0.3" = fetchurl { url = "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz"; hash = "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="; }; "playwright-core@1.58.0" = fetchurl { url = "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz"; hash = "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw=="; }; "playwright@1.58.0" = fetchurl { url = "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz"; hash = "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ=="; }; "postcss@8.5.6" = fetchurl { url = "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz"; hash = "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="; }; "prelude-ls@1.2.1" = fetchurl { url = "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz"; hash = "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="; }; "prettier@3.6.2" = fetchurl { url = "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz"; hash = "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="; }; "prop-types@15.8.1" = fetchurl { url = "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz"; hash = "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="; }; "punycode@2.3.1" = fetchurl { url = "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"; hash = "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="; }; "react-dom@18.3.1" = fetchurl { url = "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz"; hash = "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="; }; "react-i18next@16.4.1" = fetchurl { url = "https://registry.npmjs.org/react-i18next/-/react-i18next-16.4.1.tgz"; hash = "sha512-GzsYomxb1/uE7nlJm0e1qQ8f+W9I3Xirh9VoycZIahk6C8Pmv/9Fd0ek6zjf1FSgtGLElDGqwi/4FOHEGUbsEQ=="; }; "react-is@16.13.1" = fetchurl { url = "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"; hash = "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="; }; "react-refresh@0.17.0" = fetchurl { url = "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz"; hash = "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="; }; "react-select@5.10.2" = fetchurl { url = "https://registry.npmjs.org/react-select/-/react-select-5.10.2.tgz"; hash = "sha512-Z33nHdEFWq9tfnfVXaiM12rbJmk+QjFEztWLtmXqQhz6Al4UZZ9xc0wiatmGtUOCCnHN0WizL3tCMYRENX4rVQ=="; }; "react-transition-group@4.4.5" = fetchurl { url = "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz"; hash = "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="; }; "react@18.3.1" = fetchurl { url = "https://registry.npmjs.org/react/-/react-18.3.1.tgz"; hash = "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="; }; "requireindex@1.1.0" = fetchurl { url = "https://registry.npmjs.org/requireindex/-/requireindex-1.1.0.tgz"; hash = "sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg=="; }; "resolve-from@4.0.0" = fetchurl { url = "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz"; hash = "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="; }; "resolve@1.22.11" = fetchurl { url = "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz"; hash = "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="; }; "rollup@4.52.5" = fetchurl { url = "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz"; hash = "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="; }; "scheduler@0.23.2" = fetchurl { url = "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz"; hash = "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="; }; "semver@6.3.1" = fetchurl { url = "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"; hash = "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="; }; "semver@7.7.3" = fetchurl { url = "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz"; hash = "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="; }; "shebang-command@2.0.0" = fetchurl { url = "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz"; hash = "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="; }; "shebang-regex@3.0.0" = fetchurl { url = "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"; hash = "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="; }; "sonner@2.0.7" = fetchurl { url = "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz"; hash = "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="; }; "source-map-js@1.2.1" = fetchurl { url = "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"; hash = "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="; }; "source-map@0.5.7" = fetchurl { url = "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz"; hash = "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ=="; }; "strip-json-comments@3.1.1" = fetchurl { url = "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz"; hash = "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="; }; "stylis@4.2.0" = fetchurl { url = "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz"; hash = "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="; }; "supports-color@7.2.0" = fetchurl { url = "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz"; hash = "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="; }; "supports-preserve-symlinks-flag@1.0.0" = fetchurl { url = "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz"; hash = "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="; }; "tailwindcss@4.1.16" = fetchurl { url = "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz"; hash = "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA=="; }; "tapable@2.3.0" = fetchurl { url = "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz"; hash = "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="; }; "tauri-plugin-macos-permissions-api@2.3.0" = fetchurl { url = "https://registry.npmjs.org/tauri-plugin-macos-permissions-api/-/tauri-plugin-macos-permissions-api-2.3.0.tgz"; hash = "sha512-pZp0jmDySysBqrGueknd1a7Rr4XEO9aXpMv9TNrT2PDHP0MSH20njieOagsFYJ5MCVb8A+wcaK0cIkjUC2dOww=="; }; "tinyglobby@0.2.15" = fetchurl { url = "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz"; hash = "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="; }; "ts-api-utils@2.1.0" = fetchurl { url = "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz"; hash = "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="; }; "tslib@2.8.1" = fetchurl { url = "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz"; hash = "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="; }; "type-check@0.4.0" = fetchurl { url = "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz"; hash = "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="; }; "typescript@5.6.3" = fetchurl { url = "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz"; hash = "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="; }; "undici-types@7.16.0" = fetchurl { url = "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz"; hash = "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="; }; "update-browserslist-db@1.1.4" = fetchurl { url = "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz"; hash = "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="; }; "uri-js@4.4.1" = fetchurl { url = "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"; hash = "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="; }; "use-isomorphic-layout-effect@1.2.1" = fetchurl { url = "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz"; hash = "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="; }; "use-sync-external-store@1.6.0" = fetchurl { url = "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz"; hash = "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="; }; "vite@6.4.1" = fetchurl { url = "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz"; hash = "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="; }; "void-elements@3.1.0" = fetchurl { url = "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz"; hash = "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="; }; "which@2.0.2" = fetchurl { url = "https://registry.npmjs.org/which/-/which-2.0.2.tgz"; hash = "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="; }; "word-wrap@1.2.5" = fetchurl { url = "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz"; hash = "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="; }; "yallist@3.1.1" = fetchurl { url = "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz"; hash = "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="; }; "yaml@1.10.2" = fetchurl { url = "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz"; hash = "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="; }; "yocto-queue@0.1.0" = fetchurl { url = "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz"; hash = "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="; }; "zod@3.25.76" = fetchurl { url = "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz"; hash = "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="; }; "zustand@5.0.8" = fetchurl { url = "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz"; hash = "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw=="; }; } ================================================ FILE: .prettierignore ================================================ # Dependencies node_modules bun.lock package-lock.json # Build outputs dist target *.bundle.* # Tauri src-tauri/target src-tauri/gen # Generated files src/bindings.ts # Misc .DS_Store *.log ================================================ FILE: .prettierrc ================================================ { "endOfLine": "lf" } ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": [ "tauri-apps.tauri-vscode", "rust-lang.rust-analyzer", "esbenp.prettier-vscode" ] } ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Development Commands **Prerequisites:** - [Rust](https://rustup.rs/) (latest stable) - [Bun](https://bun.sh/) package manager **Core Development:** ```bash # Install dependencies bun install # Run in development mode bun run tauri dev # If cmake error on macOS: CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev # Build for production bun run tauri build # Frontend only development bun run dev # Start Vite dev server bun run build # Build frontend (TypeScript + Vite) bun run preview # Preview built frontend ``` **Model Setup (Required for Development):** ```bash # Create models directory mkdir -p src-tauri/resources/models # Download required VAD model curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx ``` ## Architecture Overview Handy is a cross-platform desktop speech-to-text application built with Tauri (Rust backend + React/TypeScript frontend). ### Core Components **Backend (Rust - src-tauri/src/):** - `lib.rs` - Main application entry point with Tauri setup, tray menu, and managers - `managers/` - Core business logic managers: - `audio.rs` - Audio recording and device management - `model.rs` - Whisper model downloading and management - `transcription.rs` - Speech-to-text processing pipeline - `audio_toolkit/` - Low-level audio processing: - `audio/` - Device enumeration, recording, resampling - `vad/` - Voice Activity Detection using Silero VAD - `commands/` - Tauri command handlers for frontend communication - `shortcut.rs` - Global keyboard shortcut handling - `settings.rs` - Application settings management **Frontend (React/TypeScript - src/):** - `App.tsx` - Main application component with onboarding flow - `components/settings/` - Settings UI components - `components/model-selector/` - Model management interface - `hooks/` - React hooks for settings and model management - `lib/types.ts` - Shared TypeScript type definitions ### Key Architecture Patterns **Manager Pattern:** Core functionality is organized into managers (Audio, Model, Transcription) that are initialized at startup and managed by Tauri's state system. **Command-Event Architecture:** Frontend communicates with backend via Tauri commands, backend sends updates via events. **Pipeline Processing:** Audio → VAD → Whisper → Text output with configurable components at each stage. ### Technology Stack **Core Libraries:** - `whisper-rs` - Local Whisper inference with GPU acceleration - `cpal` - Cross-platform audio I/O - `vad-rs` - Voice Activity Detection - `rdev` - Global keyboard shortcuts - `rubato` - Audio resampling - `rodio` - Audio playback for feedback sounds **Platform-Specific Features:** - macOS: Metal acceleration for Whisper, accessibility permissions - Windows: Vulkan acceleration, code signing - Linux: OpenBLAS + Vulkan acceleration ### Application Flow 1. **Initialization:** App starts minimized to tray, loads settings, initializes managers 2. **Model Setup:** First-run downloads preferred Whisper model (Small/Medium/Turbo/Large) 3. **Recording:** Global shortcut triggers audio recording with VAD filtering 4. **Processing:** Audio sent to Whisper model for transcription 5. **Output:** Text pasted to active application via system clipboard ### Settings System Settings are stored using Tauri's store plugin with reactive updates: - Keyboard shortcuts (configurable, supports push-to-talk) - Audio devices (microphone/output selection) - Model preferences (Small/Medium/Turbo/Large Whisper variants) - Audio feedback and translation options ### Single Instance Architecture The app enforces single instance behavior - launching when already running brings the settings window to front rather than creating a new process. ================================================ FILE: BUILD.md ================================================ # Build Instructions This guide covers how to set up the development environment and build Handy from source across different platforms. ## Prerequisites ### All Platforms - [Rust](https://rustup.rs/) (latest stable) - [Bun](https://bun.sh/) package manager - [Tauri Prerequisites](https://tauri.app/start/prerequisites/) ### Platform-Specific Requirements #### macOS - Xcode Command Line Tools - Install with: `xcode-select --install` #### Windows - Microsoft C++ Build Tools - Visual Studio 2019/2022 with C++ development tools - Or Visual Studio Build Tools 2019/2022 #### Linux - Build essentials - ALSA development libraries - Install with: ```bash # Ubuntu/Debian sudo apt update 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 # Fedora/RHEL sudo dnf groupinstall "Development Tools" sudo dnf install alsa-lib-devel pkgconf openssl-devel vulkan-devel \ gtk3-devel webkit2gtk4.1-devel libappindicator-gtk3-devel librsvg2-devel \ gtk-layer-shell gtk-layer-shell-devel \ cmake # Arch Linux sudo pacman -S base-devel alsa-lib pkgconf openssl vulkan-devel \ gtk3 webkit2gtk-4.1 libappindicator-gtk3 librsvg gtk-layer-shell \ cmake ``` ## Setup Instructions ### 1. Clone the Repository ```bash git clone git@github.com:cjpais/Handy.git cd Handy ``` ### 2. Install Dependencies ```bash bun install ``` ### 3. Start Dev Server ```bash bun tauri dev ``` ### 4. Build for Production ```bash bun run tauri build ``` This compiles a release binary and generates platform-specific bundles (deb, rpm, AppImage on Linux; dmg on macOS; msi on Windows). ## Linux Install (from source) The 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. **Install from the deb bundle** (works on any Linux distro): ```bash cd /tmp ar x /path/to/Handy/src-tauri/target/release/bundle/deb/Handy_*_amd64.deb data.tar.gz tar xzf data.tar.gz sudo cp usr/bin/handy /usr/bin/ sudo cp -r usr/lib/Handy /usr/lib/ sudo cp -r usr/share/icons/hicolor/* /usr/share/icons/hicolor/ sudo cp usr/share/applications/Handy.desktop /usr/share/applications/ ``` After subsequent rebuilds, only the binary needs re-copying: ```bash sudo cp src-tauri/target/release/handy /usr/bin/ ``` Resources only need re-copying if they change upstream (new icons, sounds, etc.). ## Troubleshooting ### AppImage build fails on Arch / rolling-release distros `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). The error from Tauri: ``` Bundling Handy_*_amd64.AppImage failed to bundle project `failed to run linuxdeploy` ``` Tauri swallows the real linuxdeploy error. To see it, run linuxdeploy manually: ```bash cd src-tauri/target/release/bundle/appimage ~/.cache/tauri/linuxdeploy-x86_64.AppImage --appimage-extract-and-run \ --appdir Handy.AppDir --plugin gtk --output appimage ``` **Workaround:** The binary, deb, and rpm bundles all build fine — only the AppImage step fails. To skip it: ```bash bun run tauri build -- --bundles deb ``` Then install using the deb extraction method above. ================================================ FILE: CHANGELOG.md ================================================ # Changelog ## [0.3.0] - 2025-07-11 ### Added - **Translate to English** setting: Added automatic translation of speech to English - Settings refactored into React hooks for better state management - Audio device switching capability - Hysteresis to VAD (Voice Activity Detection) for more stable recording ### Changed - Major audio backend refactor for improved performance and reliability - Moved audio toolkit into src-tauri directory for better permissions handling - Model files no longer need to be downloaded separately for releases - Updated settings components and transcription logic ### Fixed - Audio toolkit permissions issues - Various stability improvements ## [0.2.3] - 2025-07-03 ### Fixed - Keycode bug that was causing input issues - Whisper model optimization: switched to unquantized Whisper Turbo, updated Whisper Medium quantization to 4_1 ## [0.2.2] - 2025-07-02 ### Fixed - Removed 50ms delay feature flag for Windows (now applies to all platforms for consistency) ## [0.2.1] - 2025-07-01 ### Added - Ctrl+Space key binding for Windows platform ### Fixed - Windows crash issue - Model loading on startup when available - Windows paste functionality bug ## [0.2.0] - 2025-06-30 ### Added - **Microphone activation on demand**: More efficient resource usage - Less permissive VAD settings for better accuracy ### Changed - Improved microphone management and activation system ## [0.1.6] - 2025-06-30 ### Added - **Multiple models support**: Users can now select from different transcription models - Model selection onboarding flow - Cleanup and refactoring of model management ### Changed - Enhanced user experience with model selection interface - Better language and UI tweaks ## [0.1.5] - 2025-06-27 ### Added - **Different start and stop recording sounds**: Enhanced audio feedback - Recording sound samples for better user experience ## [0.1.4] - 2025-06-27 ### Fixed - Build issues - Auto-update functionality improvements ## [0.1.3] - 2025-06-26 ### Fixed - Paste functionality using enigo library for better cross-platform compatibility ## [0.1.2] - 2025-06-26 ### Added - **Auto-update functionality**: Application can now automatically update itself - Footer displaying current version - Improved menu system ### Changed - Better user interface for version management - Enhanced update workflow ## [0.1.1] - 2025-06-25 ### Added - **Comprehensive build system**: Support for Windows, macOS, and Linux - Windows code signing for trusted installation - Ubuntu/Linux build support with Vulkan - Model file download and packaging for releases - GitHub Actions CI/CD workflow ### Changed - Improved build process and release workflow - Better cross-platform compatibility ### Fixed - Various build-related issues across platforms ## [0.1.0] - 2025-05-16 ### Added - **Initial release** of Handy - Basic speech-to-text transcription functionality - Voice Activity Detection (VAD) for automatic recording - Cross-platform support (macOS, Windows, Linux) - **Tauri-based desktop application** with React frontend - **Global keyboard shortcuts** for activation - **Clipboard integration** for automatic text insertion - **LLM integration** for enhanced transcription processing - **Configurable settings** including: - Custom key bindings - Audio device selection - Microphone settings - Push-to-talk functionality - **System tray integration** with recording indicators - **Accessibility permissions** handling for macOS - **Settings persistence** with unified settings store - **Background operation** capability - **Multiple audio format support** with on-the-fly resampling - **Whisper model integration** for high-quality transcription - **MIT License** for open-source distribution ### Technical Implementation - Built with Tauri (Rust backend) and React (TypeScript frontend) - Audio processing with cpal and whisper-rs - Real-time transcription with performance optimizations - Cross-platform keyboard event handling - Modular architecture with managers for audio, models, and transcription ================================================ FILE: CLAUDE.md ================================================ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Development Commands **Prerequisites:** [Rust](https://rustup.rs/) (latest stable), [Bun](https://bun.sh/) ```bash # Install dependencies bun install # Run in development mode bun run tauri dev # If cmake error on macOS: CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev # Build for production bun run tauri build # Linting and formatting (run before committing) bun run lint # ESLint for frontend bun run lint:fix # ESLint with auto-fix bun run format # Prettier + cargo fmt bun run format:check # Check formatting without changes ``` **Model Setup (Required for Development):** ```bash mkdir -p src-tauri/resources/models curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx ``` ## Architecture Overview Handy is a cross-platform desktop speech-to-text app built with Tauri 2.x (Rust backend + React/TypeScript frontend). ### Backend Structure (src-tauri/src/) - `lib.rs` - Main entry point, Tauri setup, manager initialization - `managers/` - Core business logic: - `audio.rs` - Audio recording and device management - `model.rs` - Model downloading and management - `transcription.rs` - Speech-to-text processing pipeline - `history.rs` - Transcription history storage - `audio_toolkit/` - Low-level audio processing: - `audio/` - Device enumeration, recording, resampling - `vad/` - Voice Activity Detection (Silero VAD) - `commands/` - Tauri command handlers for frontend communication - `shortcut.rs` - Global keyboard shortcut handling - `settings.rs` - Application settings management ### Frontend Structure (src/) - `App.tsx` - Main component with onboarding flow - `components/settings/` - Settings UI (35+ files) - `components/model-selector/` - Model management interface - `components/onboarding/` - First-run experience - `hooks/useSettings.ts`, `useModels.ts` - State management hooks - `stores/settingsStore.ts` - Zustand store for settings - `bindings.ts` - Auto-generated Tauri type bindings (via tauri-specta) - `overlay/` - Recording overlay window code ### Key Patterns **Manager Pattern:** Core functionality organized into managers (Audio, Model, Transcription) initialized at startup and managed via Tauri state. **Command-Event Architecture:** Frontend → Backend via Tauri commands; Backend → Frontend via events. **Pipeline Processing:** Audio → VAD → Whisper/Parakeet → Text output → Clipboard/Paste **State Flow:** Zustand → Tauri Command → Rust State → Persistence (tauri-plugin-store) ## Internationalization (i18n) All user-facing strings must use i18next translations. ESLint enforces this (no hardcoded strings in JSX). **Adding new text:** 1. Add key to `src/i18n/locales/en/translation.json` 2. Use in component: `const { t } = useTranslation(); t('key.path')` **File structure:** ``` src/i18n/ ├── index.ts # i18n setup ├── languages.ts # Language metadata └── locales/ ├── en/translation.json # English (source) ├── es/translation.json # Spanish ├── fr/translation.json # French └── vi/translation.json # Vietnamese ``` ## Code Style **Rust:** - Run `cargo fmt` and `cargo clippy` before committing - Handle errors explicitly (avoid unwrap in production) - Use descriptive names, add doc comments for public APIs **TypeScript/React:** - Strict TypeScript, avoid `any` types - Functional components with hooks - Tailwind CSS for styling - Path aliases: `@/` → `./src/` ## Commit Guidelines Use conventional commits: - `feat:` new features - `fix:` bug fixes - `docs:` documentation - `refactor:` code refactoring - `chore:` maintenance ## CLI Parameters Handy supports command-line parameters on all platforms for integration with scripts, window managers, and autostart configurations. **Implementation files:** - `src-tauri/src/cli.rs` - CLI argument definitions (clap derive) - `src-tauri/src/main.rs` - Argument parsing before Tauri launch - `src-tauri/src/lib.rs` - Applying CLI overrides (setup closure + single-instance callback) - `src-tauri/src/signal_handle.rs` - `send_transcription_input()` reusable function **Available flags:** | Flag | Description | | ------------------------ | ---------------------------------------------------------------------------------- | | `--toggle-transcription` | Toggle recording on/off on a running instance (via `tauri_plugin_single_instance`) | | `--toggle-post-process` | Toggle recording with post-processing on/off on a running instance | | `--cancel` | Cancel the current operation on a running instance | | `--start-hidden` | Launch without showing the main window (tray icon still visible) | | `--no-tray` | Launch without the system tray icon (closing window quits the app) | | `--debug` | Enable debug mode with verbose (Trace) logging | **Key design decisions:** - CLI flags are runtime-only overrides — they do NOT modify persisted settings - 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 - `send_transcription_input()` in `signal_handle.rs` is shared between signal handlers and CLI to avoid code duplication - `CliArgs` is stored in Tauri managed state (`.manage()`) so it's accessible in `on_window_event` and other handlers ## Debug Mode Access debug features: `Cmd+Shift+D` (macOS) or `Ctrl+Shift+D` (Windows/Linux) ## Platform Notes - **macOS**: Metal acceleration, accessibility permissions required - **Windows**: Vulkan acceleration, code signing - **Linux**: OpenBLAS + Vulkan, limited Wayland support, overlay disabled by default ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing to Handy Thank 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. ## 📖 Philosophy Handy 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: - **Simplicity**: Clear, maintainable code over clever solutions - **Extensibility**: Make it easy for others to fork and customize - **Privacy**: Keep everything local and offline - **Accessibility**: Free tooling that belongs in everyone's hands ## 🚀 Getting Started ### Prerequisites Before you begin, ensure you have the following installed: - [Rust](https://rustup.rs/) (latest stable) - [Bun](https://bun.sh/) package manager - Platform-specific build tools (see [BUILD.md](BUILD.md)) ### Setting Up Your Development Environment 1. **Fork the repository** on GitHub 2. **Clone your fork**: ```bash git clone git@github.com:YOUR_USERNAME/Handy.git cd Handy ``` 3. **Add upstream remote**: ```bash git remote add upstream git@github.com:cjpais/Handy.git ``` 4. **Install dependencies**: ```bash bun install ``` 5. **Download required models**: ```bash mkdir -p src-tauri/resources/models curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx ``` 6. **Run in development mode**: ```bash bun run tauri dev # On macOS if you encounter cmake errors: CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev ``` For detailed platform-specific setup instructions, see [BUILD.md](BUILD.md). ### Understanding the Codebase Handy follows a clean architecture pattern: **Backend (Rust - `src-tauri/src/`):** - `lib.rs` - Main application entry point with Tauri setup - `managers/` - Core business logic (audio, model, transcription) - `audio_toolkit/` - Low-level audio processing (recording, VAD) - `commands/` - Tauri command handlers for frontend communication - `shortcut.rs` - Global keyboard shortcut handling - `settings.rs` - Application settings management **Frontend (React/TypeScript - `src/`):** - `App.tsx` - Main application component - `components/` - React UI components - `hooks/` - Reusable React hooks - `lib/types.ts` - Shared TypeScript types For more details, see the Architecture section in [README.md](README.md) or [AGENTS.md](AGENTS.md). ## 🐛 Reporting Bugs ### Before Submitting a Bug Report 1. **Search existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) 2. **Check discussions** at [github.com/cjpais/Handy/discussions](https://github.com/cjpais/Handy/discussions) 3. **Try the latest release** to see if the issue has been fixed 4. **Enable debug mode** (`Cmd/Ctrl+Shift+D`) to gather diagnostic information ### Submitting a Bug Report When creating a bug report, please include: **System Information:** - App version (found in settings or about section) - Operating System (e.g., macOS 14.1, Windows 11, Ubuntu 22.04) - CPU (e.g., Apple M2, Intel i7-12700K, AMD Ryzen 7 5800X) - GPU (e.g., Apple M2 GPU, NVIDIA RTX 4080, Intel UHD Graphics) **Bug Details:** - Clear description of the bug - Steps to reproduce - Expected behavior - Actual behavior - Screenshots or logs if applicable - Information from debug mode if relevant Use the [Bug Report template](.github/ISSUE_TEMPLATE/bug_report.md) when creating an issue. ## 💡 Suggesting Features We 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. ### Before Suggesting a Feature 1. **Search existing discussions** at [github.com/cjpais/Handy/discussions](https://github.com/cjpais/Handy/discussions) 2. **Check common feature requests**: - [Post-processing / Editing Transcripts](https://github.com/cjpais/Handy/discussions/168) - [Keyboard Shortcuts / Hotkeys](https://github.com/cjpais/Handy/discussions/211) ### Submitting a Feature Request 1. Go to [Discussions](https://github.com/cjpais/Handy/discussions) 2. Click "New discussion" 3. Choose the appropriate category (Ideas, Feature Requests, etc.) 4. Describe your feature idea including: - The problem you're trying to solve - Your proposed solution - Any alternatives you've considered - How it fits with Handy's philosophy ## 🔧 Making Code Contributions ### Before You Start **This is critical:** Before writing any code, please do the following: 1. **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. - [Open issues](https://github.com/cjpais/Handy/issues) - [Closed issues](https://github.com/cjpais/Handy/issues?q=is%3Aissue+is%3Aclosed) - [Open PRs](https://github.com/cjpais/Handy/pulls) - [Closed PRs](https://github.com/cjpais/Handy/pulls?q=is%3Apr+is%3Aclosed) 2. **If something was previously closed** - If you want to revisit a closed issue or PR, you need to: - Provide a strong argument for why it should be reconsidered - Gather community feedback first via [Discussions](https://github.com/cjpais/Handy/discussions) - Link to that discussion in your PR 3. **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. Community feedback is essential to keeping Handy the best it can be for everyone. It helps prioritize what matters most and prevents feature creep. ### Development Workflow 1. **Create a feature branch**: ```bash git checkout -b feature/your-feature-name # or git checkout -b fix/your-bug-fix ``` 2. **Make your changes**: - Write clean, maintainable code - Follow existing code style and patterns - Add comments for complex logic - Keep commits focused and atomic 3. **Test thoroughly**: - Test on your target platform(s) - Verify existing functionality still works - Test edge cases and error conditions - Use debug mode to verify audio/transcription behavior 4. **Commit your changes**: ```bash git add . git commit -m "feat: add your feature description" # or git commit -m "fix: describe the bug fix" ``` Use conventional commit messages: - `feat:` for new features - `fix:` for bug fixes - `docs:` for documentation changes - `refactor:` for code refactoring - `test:` for test additions/changes - `chore:` for maintenance tasks 5. **Keep your fork updated**: ```bash git fetch upstream git rebase upstream/main ``` 6. **Push to your fork**: ```bash git push origin feature/your-feature-name ``` 7. **Create a Pull Request**: - Go to the [Handy repository](https://github.com/cjpais/Handy) - Click "New Pull Request" - Select your fork and branch - Fill out the PR template completely, including: - Clear description of changes - Links to related issues or discussions - **Community feedback** (especially important for features) - How you tested the changes - Screenshots/videos if applicable - Breaking changes (if any) **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. ### AI Assistance Disclosure **AI-assisted PRs are welcome!** Use whatever tools help you contribute, just be upfront about it. In your PR description, please include: - Whether AI was used (yes/no) - Which tools were used (e.g., "Claude Code", "GitHub Copilot", "ChatGPT") - How extensively it was used (e.g., "generated boilerplate", "helped debug", "wrote most of the code") ### Code Style Guidelines **Rust:** - Follow standard Rust formatting (`cargo fmt`) - Run `cargo clippy` and address warnings - Use descriptive variable and function names - Add doc comments for public APIs - Handle errors explicitly (avoid unwrap in production code) **TypeScript/React:** - Use TypeScript strictly, avoid `any` types - Follow React hooks best practices - Use functional components - Keep components small and focused - Use Tailwind CSS for styling **General:** - Write self-documenting code - Add comments for non-obvious logic - Keep functions small and single-purpose - Prioritize readability over cleverness ### Testing Your Changes **Manual Testing:** - Run the app in development mode: `bun run tauri dev` - Test your changes with debug mode enabled - Verify on multiple platforms if possible - Test with different audio devices - Try various transcription scenarios **Building for Production:** ```bash bun run tauri build ``` Test the production build to ensure it works as expected. ## 📝 Documentation Contributions Documentation improvements are highly valued! You can contribute by: - Improving README.md, BUILD.md, or this CONTRIBUTING.md - Adding code comments and doc comments - Creating tutorials or guides - Improving error messages - Updating the project website content ## 🤝 Community Guidelines - **Be respectful and inclusive** - We welcome contributors of all skill levels - **Be patient** - This is maintained by a small team, responses may take time - **Be constructive** - Focus on solutions and improvements - **Be collaborative** - Help others and share knowledge - **Search first** - Check existing issues/discussions before creating new ones ## 🎯 Good First Issues Look for issues labeled `good first issue` or `help wanted` if you're new to the project. These are typically: - Well-defined and scoped - Good for learning the codebase - Mentor support available ## 📞 Getting Help - **Discord**: Join our [Discord community](https://discord.com/invite/WVBeWsNXK4) - **Discussions**: Ask questions in [GitHub Discussions](https://github.com/cjpais/Handy/discussions) - **Email**: Reach out at [contact@handy.computer](mailto:contact@handy.computer) ## 📜 License By contributing to Handy, you agree that your contributions will be licensed under the MIT License. See [LICENSE](LICENSE) for details. --- **Thank you for contributing to Handy!** Your efforts help make speech-to-text technology more accessible, private, and extensible for everyone. ================================================ FILE: CONTRIBUTING_TRANSLATIONS.md ================================================ # Contributing Translations to Handy Thank you for helping translate Handy! This guide explains how to add or improve translations. ## Quick Start 1. Fork the repository 2. Copy the English translation file to your language folder 3. Translate the values (not the keys!) 4. Submit a pull request ## File Structure Translation files are located in: ``` src/i18n/locales/ ├── en/ │ └── translation.json # English (source) ├── vi/ │ └── translation.json # Vietnamese ├── fr/ │ └── translation.json # French └── [your-language]/ └── translation.json # Your contribution! ``` ## Adding a New Language ### Step 1: Create the Language Folder Create a new folder using the [ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes): ```bash mkdir src/i18n/locales/[language-code] ``` Examples: - `de` for German - `es` for Spanish - `ja` for Japanese - `zh` for Chinese - `ko` for Korean - `pt` for Portuguese ### Step 2: Copy the English File ```bash cp src/i18n/locales/en/translation.json src/i18n/locales/[language-code]/translation.json ``` ### Step 3: Translate the Values Open the file and translate only the **values** (right side), not the keys (left side): ```json { "sidebar": { "general": "General", // ← Translate this value "advanced": "Advanced", // ← Translate this value ... } } ``` **Important:** - Keep all keys exactly the same - Preserve any `{{variables}}` in the text (e.g., `{{error}}`, `{{model}}`) - Keep the JSON structure and formatting intact ### Step 4: Register Your Language Edit `src/i18n/languages.ts` and add your language metadata: ```typescript export const LANGUAGE_METADATA: Record< string, { name: string; nativeName: string } > = { en: { name: "English", nativeName: "English" }, es: { name: "Spanish", nativeName: "Español" }, fr: { name: "French", nativeName: "Français" }, vi: { name: "Vietnamese", nativeName: "Tiếng Việt" }, de: { name: "German", nativeName: "Deutsch" }, // ← Add your language }; ``` ### Step 5: Test Your Translation 1. Run the app: `bun run tauri dev` 2. Go to Settings → General → App Language 3. Select your language 4. Verify all text displays correctly ### Step 6: Submit a Pull Request 1. Commit your changes 2. Push to your fork 3. Open a pull request with: - Language name in the title (e.g., "Add German translation") - Any notes about the translation ## Improving Existing Translations Found a typo or better translation? 1. Edit the relevant `translation.json` file 2. Submit a PR with a brief description of the change ## Translation Guidelines ### Do: - Use natural, native-sounding language - Keep translations concise (UI space is limited) - Match the tone of the English text (friendly, clear) - Preserve technical terms when appropriate (e.g., "API", "GPU") ### Don't: - Translate brand names (Handy, Whisper.cpp, OpenAI) - Change or remove `{{variables}}` - Modify JSON keys - Add extra spaces or formatting ### Handling Variables Some strings contain variables like `{{error}}` or `{{model}}`. Keep these exactly as-is: ```json // English "downloadModel": "Failed to download model: {{error}}" // French (correct) "downloadModel": "Échec du téléchargement du modèle : {{error}}" // French (incorrect - don't translate the variable!) "downloadModel": "Échec du téléchargement du modèle : {{erreur}}" ``` ### Handling Plurals Some 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. ## Questions? - Open an issue on GitHub - Join the discussion in existing translation PRs ## Currently Supported Languages | Language | Code | Status | | ---------- | ---- | ----------------- | | English | `en` | Complete (source) | | Chinese | `zh` | Complete | | French | `fr` | Complete | | German | `de` | Complete | | Japanese | `ja` | Complete | | Spanish | `es` | Complete | | Vietnamese | `vi` | Complete | ## Requested Languages We'd love help with: - Korean (`ko`) - Portuguese (`pt`) - And more! --- Thank you for making Handy accessible to more people around the world! ================================================ FILE: CRUSH.md ================================================ # Development Commands **Environment Setup:** ```bash bun install # Install dependencies mkdir -p src-tauri/resources/models curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx ``` **Development:** ```bash bun run tauri dev # Full app development CMAKE_POLICY_VERSION_MINIMUM=3.5 bun run tauri dev # macOS with cmake fix bun run dev # Frontend only (Vite) bun run build # Build frontend bun run tauri build # Production build ``` **Type Check & Build:** ```bash bunx tsc --noEmit # Type checking bun run build # Build and validate ``` # Code Style Guidelines **Rust (Backend):** - Use `anyhow::Error` for error handling with descriptive messages - Prefer `Arc>` for shared state in managers - Log with appropriate levels: `debug!`, `info!`, `eprintln!` for errors - Builder pattern for initialization chains - Snake_case for functions and variables, PascalCase for types - Separate logical sections with comment blocks: `/* ─────────── */` **TypeScript/React (Frontend):** - Functional components with TypeScript interfaces - Zod schemas for type validation and inference - `useCallback` hooks for stable function references - Destructure props with defaults: `disabled = false` - Prefer interface aliases over type aliases for objects - React.FC for explicit component typing - PascalCase for components, camelCase for variables/functions **Imports:** - Group imports: external libs, internal modules, relative imports - Use type imports for TypeScript: `import type { Settings }` - Named imports preferred over default exports **Error Handling:** - Frontend: Try/catch with user feedback, rollback optimistic updates - Backend: `?` operator with anyhow context messages - Log errors appropriately for debugging level **Component Patterns:** - Container component pattern for layout - Composition over inheritance - Prop drilling minimized with context where appropriate ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 CJ Pais Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Handy [![Discord](https://img.shields.io/badge/Discord-%235865F2.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.com/invite/WVBeWsNXK4) **A free, open source, and extensible speech-to-text application that works completely offline.** Handy is a cross-platform desktop application that provides simple, privacy-focused speech transcription. Press a shortcut, speak, and have your words appear in any text field. This happens on your own computer without sending any information to the cloud. ## Why Handy? Handy was created to fill the gap for a truly open source, extensible speech-to-text tool. As stated on [handy.computer](https://handy.computer): - **Free**: Accessibility tooling belongs in everyone's hands, not behind a paywall - **Open Source**: Together we can build further. Extend Handy for yourself and contribute to something bigger - **Private**: Your voice stays on your computer. Get transcriptions without sending audio to the cloud - **Simple**: One tool, one job. Transcribe what you say and put it into a text box Handy isn't trying to be the best speech-to-text app—it's trying to be the most forkable one. ## How It Works 1. **Press** a configurable keyboard shortcut to start/stop recording (or use push-to-talk mode) 2. **Speak** your words while the shortcut is active 3. **Release** and Handy processes your speech using Whisper 4. **Get** your transcribed text pasted directly into whatever app you're using The process is entirely local: - Silence is filtered using VAD (Voice Activity Detection) with Silero - Transcription uses your choice of models: - **Whisper models** (Small/Medium/Turbo/Large) with GPU acceleration when available - **Parakeet V3** - CPU-optimized model with excellent performance and automatic language detection - Works on Windows, macOS, and Linux ## Quick Start ### Installation 1. Download the latest release from the [releases page](https://github.com/cjpais/Handy/releases) or the [website](https://handy.computer) - **macOS**: Also available via [Homebrew cask](https://formulae.brew.sh/cask/handy): `brew install --cask handy` - **Windows**: Also available via [winget](https://github.com/microsoft/winget-pkgs): `winget install cjpais.Handy` \ **Note:** The Homebrew cask and winget package are not maintained by the Handy developers. 2. Install the application 3. Launch Handy and grant necessary system permissions (microphone, accessibility) 4. Configure your preferred keyboard shortcuts in Settings 5. Start transcribing! ### Development Setup For detailed build instructions including platform-specific requirements, see [BUILD.md](BUILD.md). ## Integrations Install handy Raycast Extension Control Handy from [Raycast](https://www.raycast.com) — start/stop recording, browse transcript history, manage dictionary, switch models and languages. [Source](https://github.com/mattiacolombomc/raycast-handy) · by [@mattiacolombomc](https://github.com/mattiacolombomc) ## Architecture Handy is built as a Tauri application combining: - **Frontend**: React + TypeScript with Tailwind CSS for the settings UI - **Backend**: Rust for system integration, audio processing, and ML inference - **Core Libraries**: - `whisper-rs`: Local speech recognition with Whisper models - `transcription-rs`: CPU-optimized speech recognition with Parakeet models - `cpal`: Cross-platform audio I/O - `vad-rs`: Voice Activity Detection - `rdev`: Global keyboard shortcuts and system events - `rubato`: Audio resampling ### Debug Mode Handy includes an advanced debug mode for development and troubleshooting. Access it by pressing: - **macOS**: `Cmd+Shift+D` - **Windows/Linux**: `Ctrl+Shift+D` ### CLI Parameters Handy supports command-line flags for controlling a running instance and customizing startup behavior. These work on all platforms (macOS, Windows, Linux). **Remote control flags** (sent to an already-running instance via the single-instance plugin): ```bash handy --toggle-transcription # Toggle recording on/off handy --toggle-post-process # Toggle recording with post-processing on/off handy --cancel # Cancel the current operation ``` **Startup flags:** ```bash handy --start-hidden # Start without showing the main window handy --no-tray # Start without the system tray icon handy --debug # Enable debug mode with verbose logging handy --help # Show all available flags ``` Flags can be combined for autostart scenarios: ```bash handy --start-hidden --no-tray ``` > **macOS tip:** When Handy is installed as an app bundle, invoke the binary directly: > > ```bash > /Applications/Handy.app/Contents/MacOS/Handy --toggle-transcription > ``` ## Known Issues & Current Limitations This project is actively being developed and has some [known issues](https://github.com/cjpais/Handy/issues). We believe in transparency about the current state: ### Major Issues (Help Wanted) **Whisper Model Crashes:** - Whisper models crash on certain system configurations (Windows and Linux) - Does not affect all systems - issue is configuration-dependent - If you experience crashes and are a developer, please help to fix and provide debug logs! **Wayland Support (Linux):** - Limited support for Wayland display server - 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) ### Linux Notes **Text Input Tools:** For reliable text input on Linux, install the appropriate tool for your display server: | Display Server | Recommended Tool | Install Command | | -------------- | ---------------- | -------------------------------------------------- | | X11 | `xdotool` | `sudo apt install xdotool` | | Wayland | `wtype` | `sudo apt install wtype` | | Both | `dotool` | `sudo apt install dotool` (requires `input` group) | - **X11**: Install `xdotool` for both direct typing and clipboard paste shortcuts - **Wayland**: Install `wtype` (preferred) or `dotool` for text input to work correctly - **dotool setup**: Requires adding your user to the `input` group: `sudo usermod -aG input $USER` (then log out and back in) Without these tools, Handy falls back to enigo which may have limited compatibility, especially on Wayland. **Other Notes:** - **Runtime library dependency (`libgtk-layer-shell.so.0`)**: - 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: | Distro | Package to install | Example command | | ------------- | --------------------- | -------------------------------------- | | Ubuntu/Debian | `libgtk-layer-shell0` | `sudo apt install libgtk-layer-shell0` | | Fedora/RHEL | `gtk-layer-shell` | `sudo dnf install gtk-layer-shell` | | Arch Linux | `gtk-layer-shell` | `sudo pacman -S gtk-layer-shell` | - For building from source on Ubuntu/Debian, you may also need `libgtk-layer-shell-dev`. - The recording overlay is disabled by default on Linux (`Overlay Position: None`) because certain compositors treat it as the active window. When the overlay is visible it can steal focus, which prevents 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. - If you are having trouble with the app, running with the environment variable `WEBKIT_DISABLE_DMABUF_RENDERER=1` may help - **Global keyboard shortcuts (Wayland):** On Wayland, system-level shortcuts must be configured through your desktop environment or window manager. Use the [CLI flags](#cli-parameters) as the command for your custom shortcut. **GNOME:** 1. Open **Settings > Keyboard > Keyboard Shortcuts > Custom Shortcuts** 2. Click the **+** button to add a new shortcut 3. Set the **Name** to `Toggle Handy Transcription` 4. Set the **Command** to `handy --toggle-transcription` 5. Click **Set Shortcut** and press your desired key combination (e.g., `Super+O`) **KDE Plasma:** 1. Open **System Settings > Shortcuts > Custom Shortcuts** 2. Click **Edit > New > Global Shortcut > Command/URL** 3. Name it `Toggle Handy Transcription` 4. In the **Trigger** tab, set your desired key combination 5. In the **Action** tab, set the command to `handy --toggle-transcription` **Sway / i3:** Add to your config file (`~/.config/sway/config` or `~/.config/i3/config`): ```ini bindsym $mod+o exec handy --toggle-transcription ``` **Hyprland:** Add to your config file (`~/.config/hypr/hyprland.conf`): ```ini bind = $mainMod, O, exec, handy --toggle-transcription ``` - 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: | Signal | Action | Example | | --------- | ----------------------------------------- | ---------------------- | | `SIGUSR2` | Toggle transcription | `pkill -USR2 -n handy` | | `SIGUSR1` | Toggle transcription with post-processing | `pkill -USR1 -n handy` | Example Sway config: ```ini bindsym $mod+o exec pkill -USR2 -n handy bindsym $mod+p exec pkill -USR1 -n handy ``` `pkill` here simply delivers the signal—it does not terminate the process. ### Platform Support - **macOS (both Intel and Apple Silicon)** - **x64 Windows** - **x64 Linux** ### System Requirements/Recommendations The 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. **For Whisper Models:** - **macOS**: M series Mac, Intel Mac - **Windows**: Intel, AMD, or NVIDIA GPU - **Linux**: Intel, AMD, or NVIDIA GPU - Ubuntu 22.04, 24.04 **For Parakeet V3 Model:** - **CPU-only operation** - runs on a wide variety of hardware - **Minimum**: Intel Skylake (6th gen) or equivalent AMD processors - **Performance**: ~5x real-time speed on mid-range hardware (tested on i5) - **Automatic language detection** - no manual language selection required ## Roadmap & Active Development We're actively working on several features and improvements. Contributions and feedback are welcome! ### In Progress **Debug Logging:** - Adding debug logging to a file to help diagnose issues **macOS Keyboard Improvements:** - Support for Globe key as transcription trigger - A rewrite of global shortcut handling for MacOS, and potentially other OS's too. **Opt-in Analytics:** - Collect anonymous usage data to help improve Handy - Privacy-first approach with clear opt-in **Settings Refactoring:** - Cleanup and refactor settings system which is becoming bloated and messy - Implement better abstractions for settings management **Tauri Commands Cleanup:** - Abstract and organize Tauri command patterns - Investigate tauri-specta for improved type safety and organization ## Troubleshooting ### Manual Model Installation (For Proxy Users or Network Restrictions) If 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. #### Step 1: Find Your App Data Directory 1. Open Handy settings 2. Navigate to the **About** section 3. Copy the "App Data Directory" path shown there, or use the shortcuts: - **macOS**: `Cmd+Shift+D` to open debug menu - **Windows/Linux**: `Ctrl+Shift+D` to open debug menu The typical paths are: - **macOS**: `~/Library/Application Support/com.pais.handy/` - **Windows**: `C:\Users\{username}\AppData\Roaming\com.pais.handy\` - **Linux**: `~/.config/com.pais.handy/` #### Step 2: Create Models Directory Inside your app data directory, create a `models` folder if it doesn't already exist: ```bash # macOS/Linux mkdir -p ~/Library/Application\ Support/com.pais.handy/models # Windows (PowerShell) New-Item -ItemType Directory -Force -Path "$env:APPDATA\com.pais.handy\models" ``` #### Step 3: Download Model Files Download the models you want from below **Whisper Models (single .bin files):** - Small (487 MB): `https://blob.handy.computer/ggml-small.bin` - Medium (492 MB): `https://blob.handy.computer/whisper-medium-q4_1.bin` - Turbo (1600 MB): `https://blob.handy.computer/ggml-large-v3-turbo.bin` - Large (1100 MB): `https://blob.handy.computer/ggml-large-v3-q5_0.bin` **Parakeet Models (compressed archives):** - V2 (473 MB): `https://blob.handy.computer/parakeet-v2-int8.tar.gz` - V3 (478 MB): `https://blob.handy.computer/parakeet-v3-int8.tar.gz` #### Step 4: Install Models **For Whisper Models (.bin files):** Simply place the `.bin` file directly into the `models` directory: ``` {app_data_dir}/models/ ├── ggml-small.bin ├── whisper-medium-q4_1.bin ├── ggml-large-v3-turbo.bin └── ggml-large-v3-q5_0.bin ``` **For Parakeet Models (.tar.gz archives):** 1. Extract the `.tar.gz` file 2. Place the **extracted directory** into the `models` folder 3. The directory must be named exactly as follows: - **Parakeet V2**: `parakeet-tdt-0.6b-v2-int8` - **Parakeet V3**: `parakeet-tdt-0.6b-v3-int8` Final structure should look like: ``` {app_data_dir}/models/ ├── parakeet-tdt-0.6b-v2-int8/ (directory with model files inside) │ ├── (model files) │ └── (config files) └── parakeet-tdt-0.6b-v3-int8/ (directory with model files inside) ├── (model files) └── (config files) ``` **Important Notes:** - For Parakeet models, the extracted directory name **must** match exactly as shown above - Do not rename the `.bin` files for Whisper models—use the exact filenames from the download URLs - After placing the files, restart Handy to detect the new models #### Step 5: Verify Installation 1. Restart Handy 2. Open Settings → Models 3. Your manually installed models should now appear as "Downloaded" 4. Select the model you want to use and test transcription ### Custom Whisper Models Handy can auto-discover custom Whisper GGML models placed in the `models` directory. This is useful for users who want to use fine-tuned or community models not included in the default model list. **How to use:** 1. Obtain a Whisper model in GGML `.bin` format (e.g., from [Hugging Face](https://huggingface.co/models?search=whisper%20ggml)) 2. Place the `.bin` file in your `models` directory (see paths above) 3. Restart Handy to discover the new model 4. The model will appear in the "Custom Models" section of the Models settings page **Important:** - Community models are user-provided and may not receive troubleshooting assistance - The model must be a valid Whisper GGML format (`.bin` file) - Model name is derived from the filename (e.g., `my-custom-model.bin` → "My Custom Model") ### How to Contribute 1. **Check existing issues** at [github.com/cjpais/Handy/issues](https://github.com/cjpais/Handy/issues) 2. **Fork the repository** and create a feature branch 3. **Test thoroughly** on your target platform 4. **Submit a pull request** with clear description of changes 5. **Join the discussion** - reach out at [contact@handy.computer](mailto:contact@handy.computer) The goal is to create both a useful tool and a foundation for others to build upon—a well-patterned, simple codebase that serves the community. ## Sponsors
We're grateful for the support of our sponsors who help make Handy possible:

Wordcab        Epicenter        Bolt AI
## Related Projects - **[Handy CLI](https://github.com/cjpais/handy-cli)** - The original Python command-line version - **[handy.computer](https://handy.computer)** - Project website with demos and documentation ## License MIT License - see [LICENSE](LICENSE) file for details. ## Acknowledgments - **Whisper** by OpenAI for the speech recognition model - **whisper.cpp and ggml** for amazing cross-platform whisper inference/acceleration - **Silero** for great lightweight VAD - **Tauri** team for the excellent Rust-based app framework - **Community contributors** helping make Handy better ================================================ FILE: eslint.config.js ================================================ import i18next from "eslint-plugin-i18next"; import tsParser from "@typescript-eslint/parser"; export default [ { files: ["src/**/*.{ts,tsx}"], languageOptions: { parser: tsParser, parserOptions: { ecmaFeatures: { jsx: true, }, }, }, plugins: { i18next, }, rules: { // Catch text in JSX that should be translated "i18next/no-literal-string": [ "error", { markupOnly: true, // Only check JSX content, not all strings ignoreAttribute: [ "className", "style", "type", "id", "name", "key", "data-*", "aria-*", ], // Ignore common non-translatable attributes }, ], }, }, ]; ================================================ FILE: flake.nix ================================================ { description = "Handy - A free, open source, and extensible speech-to-text application that works completely offline"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; # bun2nix: generates per-package Nix fetchurl expressions from bun.lock, # replacing the old FOD approach where a single hash covered the entire # node_modules directory (that hash would break on bun version changes). # See: https://github.com/nix-community/bun2nix bun2nix = { url = "github:nix-community/bun2nix/2.0.8"; inputs.nixpkgs.follows = "nixpkgs"; }; }; outputs = { self, nixpkgs, bun2nix, }: let supportedSystems = [ "x86_64-linux" "aarch64-linux" ]; forAllSystems = nixpkgs.lib.genAttrs supportedSystems; # Read version from Cargo.toml cargoToml = fromTOML (builtins.readFile ./src-tauri/Cargo.toml); version = cargoToml.package.version; # Shared native library dependencies for both package build and dev shell. # Keep in sync: if a native dep is needed for compilation, add it here. commonNativeDeps = pkgs: with pkgs; [ webkitgtk_4_1 gtk3 glib libsoup_3 alsa-lib onnxruntime libayatana-appindicator libevdev libxtst gtk-layer-shell openssl vulkan-loader vulkan-headers shaderc ]; # GStreamer plugins for WebKitGTK audio/video gstPlugins = pkgs: with pkgs.gst_all_1; [ gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly ]; # Shared environment variables for Rust/native builds commonEnv = pkgs: let lib = pkgs.lib; in { LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; BINDGEN_EXTRA_CLANG_ARGS = "-isystem ${pkgs.llvmPackages.libclang.lib}/lib/clang/${lib.getVersion pkgs.llvmPackages.libclang}/include -isystem ${pkgs.glibc.dev}/include"; ORT_LIB_LOCATION = "${pkgs.onnxruntime}/lib"; ORT_PREFER_DYNAMIC_LINK = "1"; GST_PLUGIN_SYSTEM_PATH_1_0 = "${lib.makeSearchPathOutput "lib" "lib/gstreamer-1.0" (gstPlugins pkgs)}"; }; # TODO: Remove this overlay once nixpkgs ships onnxruntime ≥ 1.24. # Tracking PR: https://github.com/NixOS/nixpkgs/pull/499389 # ort-sys 2.0.0-rc.12 requires ONNX Runtime 1.24 (API v24); # nixpkgs only ships 1.23.2, so use MS prebuilt binaries. onnxruntimeOverlay = (final: prev: { onnxruntime = let onnxVersion = "1.24.2"; platform = { x86_64-linux = { name = "linux-x64"; hash = "sha256-Q3JUdLpWY2QuF2hHF5Rmk4UOIAXvvXJKxy2ieP6tJeY="; }; aarch64-linux = { name = "linux-aarch64"; hash = "sha256-spla8PQ3xOAi/YAcV/tcJf0f5mDNM9JutHGUSQpbRsQ="; }; }.${final.system}; in prev.stdenv.mkDerivation { pname = "onnxruntime"; version = onnxVersion; src = prev.fetchurl { url = "https://github.com/microsoft/onnxruntime/releases/download/v${onnxVersion}/onnxruntime-${platform.name}-${onnxVersion}.tgz"; hash = platform.hash; }; sourceRoot = "onnxruntime-${platform.name}-${onnxVersion}"; nativeBuildInputs = [ prev.autoPatchelfHook ]; buildInputs = [ prev.stdenv.cc.cc.lib ]; installPhase = '' runHook preInstall mkdir -p $out/lib $out/include cp -r lib/* $out/lib/ cp -r include/* $out/include/ runHook postInstall ''; meta = prev.onnxruntime.meta // { description = "ONNX Runtime ${onnxVersion} (prebuilt by Microsoft)"; }; }; }); in { packages = forAllSystems ( system: let pkgs = import nixpkgs { inherit system; overlays = [ bun2nix.overlays.default onnxruntimeOverlay ]; }; lib = pkgs.lib; in { handy = pkgs.rustPlatform.buildRustPackage { pname = "handy"; inherit version; src = self; cargoRoot = "src-tauri"; cargoLock = { lockFile = ./src-tauri/Cargo.lock; # Automatically fetch git dependencies using builtins.fetchGit. # This eliminates the need for manual outputHashes that had to be # updated every time a git dependency changed in Cargo.lock. # Safe for standalone flakes (not allowed in nixpkgs, it is needed something like crate2nix). allowBuiltinFetchGit = true; }; postPatch = '' ${pkgs.jq}/bin/jq 'del(.build.beforeBuildCommand) | .bundle.createUpdaterArtifacts = false' \ src-tauri/tauri.conf.json > $TMPDIR/tauri.conf.json cp $TMPDIR/tauri.conf.json src-tauri/tauri.conf.json # Strip postinstall hook — it runs check-nix-deps.ts which is only # needed during local development, not inside the Nix sandbox. ${pkgs.jq}/bin/jq 'del(.scripts.postinstall)' \ package.json > $TMPDIR/package.json cp $TMPDIR/package.json package.json # Point libappindicator-sys to the Nix store path substituteInPlace \ $cargoDepsCopy/libappindicator-sys-*/src/lib.rs \ --replace-fail \ "libayatana-appindicator3.so.1" \ "${pkgs.libayatana-appindicator}/lib/libayatana-appindicator3.so.1" # Disable cbindgen in ferrous-opencc (calls cargo metadata which fails in sandbox) # Upstream removed this call in v0.3.1+ substituteInPlace $cargoDepsCopy/ferrous-opencc-0.2.3/build.rs \ --replace-fail '.expect("Unable to generate bindings")' '.ok();' substituteInPlace $cargoDepsCopy/ferrous-opencc-0.2.3/build.rs \ --replace-fail '.write_to_file("opencc.h");' '// skipped' ''; # Bun dependencies: fetched per-package using hashes from .nix/bun.nix. # This file is auto-generated by `bunx bun2nix -o .nix/bun.nix` and # kept in sync via the postinstall hook in package.json. # To regenerate manually: bun scripts/check-nix-deps.ts bunDeps = pkgs.bun2nix.fetchBunDeps { bunNix = ./.nix/bun.nix; }; nativeBuildInputs = with pkgs; [ cargo-tauri.hook pkg-config wrapGAppsHook4 bun # pkgs.bun2nix (from overlay), not the flake input — `with pkgs;` # doesn't shadow function arguments in Nix. pkgs.bun2nix.hook # Sets up node_modules from pre-fetched bun cache jq cmake llvmPackages.libclang shaderc ]; preBuild = '' # bun2nix.hook has already set up node_modules from pre-fetched cache. # Build the frontend with bun (tsc + vite). export HOME=$TMPDIR bun run build ''; # Tests require runtime resources (audio devices, model files, GPU/Vulkan) # not available in the Nix build sandbox doCheck = false; # The tauri hook's installPhase expects target/ in cwd, but our # cargoRoot puts it under src-tauri/. Override to extract the DEB. installPhase = '' runHook preInstall mkdir -p $out cd src-tauri mv target/${pkgs.stdenv.hostPlatform.rust.rustcTarget}/release/bundle/deb/*/data/usr/* $out/ runHook postInstall ''; buildInputs = commonNativeDeps pkgs ++ (with pkgs; [ glib-networking libx11 ]) ++ gstPlugins pkgs; env = commonEnv pkgs // { OPENSSL_NO_VENDOR = "1"; }; preFixup = '' gappsWrapperArgs+=( --set WEBKIT_DISABLE_DMABUF_RENDERER 1 --set ALSA_PLUGIN_DIR "${pkgs.pipewire}/lib/alsa-lib:${pkgs.alsa-plugins}/lib/alsa-lib" --prefix LD_LIBRARY_PATH : "${ lib.makeLibraryPath [ pkgs.vulkan-loader pkgs.onnxruntime ] }" ) ''; meta = { description = "A free, open source, and extensible speech-to-text application that works completely offline"; homepage = "https://github.com/cjpais/Handy"; license = lib.licenses.mit; mainProgram = "handy"; platforms = supportedSystems; }; }; default = self.packages.${system}.handy; } ); # NixOS module for system-level integration (udev, input group) nixosModules.default = { lib, pkgs, ... }: { imports = [ ./nix/module.nix ]; programs.handy.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.handy; }; # Home-manager module for per-user service homeManagerModules.default = { lib, pkgs, ... }: { imports = [ ./nix/hm-module.nix ]; services.handy.package = lib.mkDefault self.packages.${pkgs.stdenv.hostPlatform.system}.handy; }; # Development shell for building from source devShells = forAllSystems ( system: let pkgs = import nixpkgs { inherit system; overlays = [ onnxruntimeOverlay ]; }; in { default = pkgs.mkShell { buildInputs = commonNativeDeps pkgs ++ (with pkgs; [ # Rust toolchain rustc cargo rust-analyzer clippy # Frontend nodejs bun # Build tools cargo-tauri pkg-config llvmPackages.libclang cmake ]); inherit (commonEnv pkgs) LIBCLANG_PATH BINDGEN_EXTRA_CLANG_ARGS ORT_LIB_LOCATION ORT_PREFER_DYNAMIC_LINK GST_PLUGIN_SYSTEM_PATH_1_0; LD_LIBRARY_PATH = "${pkgs.lib.makeLibraryPath [ pkgs.libayatana-appindicator pkgs.onnxruntime pkgs.vulkan-loader ]}"; # Same as wrapGAppsHook4 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"; shellHook = '' echo "Handy development environment" bun install echo "Run 'bun run tauri dev' to start" ''; }; } ); }; } ================================================ FILE: index.html ================================================ handy
================================================ FILE: nix/hm-module.nix ================================================ # Home-manager module for Handy speech-to-text # # Provides a systemd user service for autostart. # Usage: imports = [ handy.homeManagerModules.default ]; # services.handy.enable = true; { config, lib, pkgs, ... }: let cfg = config.services.handy; in { options.services.handy = { enable = lib.mkEnableOption "Handy speech-to-text user service"; package = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "handy.packages.\${system}.handy"; description = "The Handy package to use."; }; }; config = lib.mkIf cfg.enable { systemd.user.services.handy = { Unit = { Description = "Handy speech-to-text"; After = [ "graphical-session.target" ]; PartOf = [ "graphical-session.target" ]; }; Service = { ExecStart = "${cfg.package}/bin/handy"; Restart = "on-failure"; RestartSec = 5; }; Install.WantedBy = [ "graphical-session.target" ]; }; }; } ================================================ FILE: nix/module.nix ================================================ # NixOS module for Handy speech-to-text # # Handles system-level configuration that the package wrapper cannot: # - udev rule for /dev/uinput (rdev grab() needs it for virtual input) # # Note: users must add themselves to the "input" group for evdev hotkey access. # # Usage in your flake: # # inputs.handy.url = "github:cjpais/Handy"; # # nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { # modules = [ # handy.nixosModules.default # { programs.handy.enable = true; } # ]; # }; { config, lib, pkgs, ... }: let cfg = config.programs.handy; in { options.programs.handy = { enable = lib.mkEnableOption "Handy offline speech-to-text"; package = lib.mkOption { type = lib.types.package; defaultText = lib.literalExpression "handy.packages.\${system}.handy"; description = "The Handy package to use."; }; }; config = lib.mkIf cfg.enable { environment.systemPackages = [ cfg.package ]; # rdev grab() creates virtual input devices via /dev/uinput. # Default permissions are crw------- root root — open it to the input group. services.udev.extraRules = '' KERNEL=="uinput", GROUP="input", MODE="0660" ''; }; } ================================================ FILE: package.json ================================================ { "name": "handy-app", "private": true, "version": "0.7.12", "type": "module", "scripts": { "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", "lint": "eslint src", "lint:fix": "eslint src --fix", "format": "prettier --write . && cd src-tauri && cargo fmt", "format:check": "prettier --check . && cd src-tauri && cargo fmt -- --check", "format:frontend": "prettier --write .", "format:backend": "cd src-tauri && cargo fmt", "test:playwright": "playwright test", "test:playwright:ui": "playwright test --ui", "check:translations": "bun scripts/check-translations.ts", "postinstall": "bun scripts/check-nix-deps.ts" }, "dependencies": { "@tailwindcss/vite": "^4.1.16", "@tauri-apps/api": "^2.10.0", "@tauri-apps/plugin-autostart": "~2.5.1", "@tauri-apps/plugin-clipboard-manager": "~2.3.2", "@tauri-apps/plugin-dialog": "~2.6", "@tauri-apps/plugin-fs": "~2.4.4", "@tauri-apps/plugin-global-shortcut": "~2.3.1", "@tauri-apps/plugin-opener": "^2.5.2", "@tauri-apps/plugin-os": "~2.3.2", "@tauri-apps/plugin-process": "~2.3.1", "@tauri-apps/plugin-sql": "~2.3.1", "@tauri-apps/plugin-store": "~2.4.1", "@tauri-apps/plugin-updater": "~2.10.0", "i18next": "^25.7.2", "immer": "^11.1.3", "lucide-react": "^0.542.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^16.4.1", "react-select": "^5.8.0", "sonner": "^2.0.7", "tailwindcss": "^4.1.16", "tauri-plugin-macos-permissions-api": "2.3.0", "zod": "^3.25.76", "zustand": "^5.0.8" }, "devDependencies": { "@playwright/test": "^1.58.0", "@tauri-apps/cli": "^2.10.0", "@types/node": "^24.9.1", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/react-select": "^5.0.1", "@typescript-eslint/eslint-plugin": "^8.49.0", "@typescript-eslint/parser": "^8.49.0", "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.39.1", "eslint-plugin-i18next": "^6.1.3", "prettier": "^3.6.2", "typescript": "~5.6.3", "vite": "^6.4.1" } } ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./tests", fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, workers: process.env.CI ? 1 : undefined, reporter: "html", use: { baseURL: "http://localhost:1420", trace: "on-first-retry", }, projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"] }, }, ], webServer: { command: "bunx vite dev", url: "http://localhost:1420", reuseExistingServer: !process.env.CI, timeout: 30000, }, }); ================================================ FILE: scripts/check-nix-deps.ts ================================================ // scripts/check-nix-deps.ts — Keep .nix/bun.nix in sync with bun.lock // // Handy uses bun2nix to generate per-package Nix fetchurl expressions from // bun.lock. This replaces the old FOD (Fixed-Output Derivation) approach // where a single hash covered the entire node_modules — that hash would // break whenever the bun version in nixpkgs changed, even without any // dependency updates. // // How it works: // 1. Computes sha256 of bun.lock // 2. Compares with stored hash in .nix/bun-lock-hash // 3. If they match — nothing to do (~2ms) // 4. If they differ — runs `bunx bun2nix` to regenerate .nix/bun.nix // // When it runs: // - Automatically via "postinstall" in package.json — triggers after every // bun install / bun add / bun remove / bun update // - Can also be run manually: bun scripts/check-nix-deps.ts // // What to commit: // If the script regenerated .nix/bun.nix, commit it together with bun.lock: // git add bun.lock .nix/bun.nix .nix/bun-lock-hash import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { join, resolve } from "path"; const root = resolve(import.meta.dirname, ".."); const nixDir = join(root, ".nix"); const lockFile = join(root, "bun.lock"); const hashFile = join(nixDir, "bun-lock-hash"); const nixFile = join(nixDir, "bun.nix"); // Skip on Windows — bun2nix is Nix-only and hangs on Windows CI if (process.platform === "win32") process.exit(0); // No bun.lock — nothing to do if (!existsSync(lockFile)) process.exit(0); // Ensure .nix directory exists mkdirSync(nixDir, { recursive: true }); // Compute sha256 of the current bun.lock const currentHash = new Bun.CryptoHasher("sha256") .update(readFileSync(lockFile)) .digest("hex"); // Read the previously stored hash (empty if first run) const storedHash = existsSync(hashFile) ? readFileSync(hashFile, "utf-8").trim() : ""; // If hashes match, bun.nix is up to date — nothing to do if (currentHash === storedHash) process.exit(0); // bun.lock has changed — regenerate the Nix dependency file console.log( `[check-nix-deps] bun.lock has changed, regenerating ${nixFile}...`, ); const result = Bun.spawnSync(["bunx", "bun2nix", "-o", nixFile], { cwd: root, stdio: ["inherit", "inherit", "inherit"], }); if (result.exitCode !== 0) { console.warn( "[check-nix-deps] Warning: bunx bun2nix failed. .nix/bun.nix may be outdated.", ); console.warn( "[check-nix-deps] Nix users: run `bunx bun2nix -o .nix/bun.nix` manually.", ); console.warn( "[check-nix-deps] Non-Nix users: this is safe to ignore, CI will catch it.", ); // Exit 0 so that `bun install` is not blocked for non-Nix developers. // CI validates bun.nix independently. process.exit(0); } writeFileSync(hashFile, currentHash + "\n"); console.log(`[check-nix-deps] Updated ${nixFile}`); console.log( "[check-nix-deps] Don't forget to commit: .nix/bun.nix .nix/bun-lock-hash", ); ================================================ FILE: scripts/check-translations.ts ================================================ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Configuration const LOCALES_DIR = path.join(__dirname, "..", "src", "i18n", "locales"); const REFERENCE_LANG = "en"; type TranslationData = Record; interface ValidationResult { valid: boolean; missing: string[][]; extra: string[][]; } function getLanguages(): string[] { const entries = fs.readdirSync(LOCALES_DIR, { withFileTypes: true }); return entries .filter((entry) => entry.isDirectory() && entry.name !== REFERENCE_LANG) .map((entry) => entry.name) .sort(); } const LANGUAGES = getLanguages(); // Colors for terminal output const colors: Record = { reset: "\x1b[0m", red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m", blue: "\x1b[34m", }; function colorize(text: string, color: string): string { return `${colors[color]}${text}${colors.reset}`; } function getAllKeyPaths( obj: TranslationData, prefix: string[] = [], ): string[][] { let paths: string[][] = []; for (const key in obj) { if (!Object.hasOwn(obj, key)) continue; const currentPath = prefix.concat([key]); const value = obj[key]; if (typeof value === "object" && value !== null && !Array.isArray(value)) { paths = paths.concat( getAllKeyPaths(value as TranslationData, currentPath), ); } else { paths.push(currentPath); } } return paths; } function hasKeyPath(obj: TranslationData, keyPath: string[]): boolean { let current: unknown = obj; for (const key of keyPath) { if ( typeof current !== "object" || current === null || (current as Record)[key] === undefined ) { return false; } current = (current as Record)[key]; } return true; } function loadTranslationFile(lang: string): TranslationData | null { const filePath = path.join(LOCALES_DIR, lang, "translation.json"); try { const content = fs.readFileSync(filePath, "utf8"); return JSON.parse(content) as TranslationData; } catch (error) { console.error(colorize(`✗ Error loading ${lang}/translation.json:`, "red")); console.error(` ${(error as Error).message}`); return null; } } function validateTranslations(): void { console.log(colorize("\n🌍 Translation Consistency Check\n", "blue")); // Load reference file console.log(`Loading reference language: ${REFERENCE_LANG}`); const referenceData = loadTranslationFile(REFERENCE_LANG); if (!referenceData) { console.error( colorize(`\n✗ Failed to load reference file (${REFERENCE_LANG})`, "red"), ); process.exit(1); } // Get all key paths from reference const referenceKeyPaths = getAllKeyPaths(referenceData); console.log(`Reference has ${referenceKeyPaths.length} keys\n`); // Track validation results let hasErrors = false; const results: Record = {}; // Validate each language for (const lang of LANGUAGES) { const langData = loadTranslationFile(lang); if (!langData) { hasErrors = true; results[lang] = { valid: false, missing: [], extra: [] }; continue; } // Find missing keys const missing = referenceKeyPaths.filter( (keyPath) => !hasKeyPath(langData, keyPath), ); // Find extra keys (keys in language but not in reference) const langKeyPaths = getAllKeyPaths(langData); const extra = langKeyPaths.filter( (keyPath) => !hasKeyPath(referenceData, keyPath), ); results[lang] = { valid: missing.length === 0 && extra.length === 0, missing, extra, }; if (missing.length > 0 || extra.length > 0) { hasErrors = true; } } // Print results console.log(colorize("Results:", "blue")); console.log("─".repeat(60)); for (const lang of LANGUAGES) { const result = results[lang]; if (result.valid) { console.log( colorize(`✓ ${lang.toUpperCase()}: All keys present`, "green"), ); } else { console.log(colorize(`✗ ${lang.toUpperCase()}: Issues found`, "red")); if (result.missing.length > 0) { console.log( colorize(` Missing ${result.missing.length} keys:`, "yellow"), ); result.missing.slice(0, 10).forEach((keyPath) => { console.log(` - ${keyPath.join(".")}`); }); if (result.missing.length > 10) { console.log( colorize( ` ... and ${result.missing.length - 10} more`, "yellow", ), ); } } if (result.extra.length > 0) { console.log( colorize( ` Extra ${result.extra.length} keys (not in reference):`, "yellow", ), ); result.extra.slice(0, 10).forEach((keyPath) => { console.log(` - ${keyPath.join(".")}`); }); if (result.extra.length > 10) { console.log( colorize(` ... and ${result.extra.length - 10} more`, "yellow"), ); } } console.log(""); } } console.log("─".repeat(60)); // Summary const validCount = Object.values(results).filter((r) => r.valid).length; const totalCount = LANGUAGES.length; if (hasErrors) { console.log( colorize( `\n✗ Validation failed: ${validCount}/${totalCount} languages passed`, "red", ), ); process.exit(1); } else { console.log( colorize( `\n✓ All ${totalCount} languages have complete translations!`, "green", ), ); process.exit(0); } } // Run validation validateTranslations(); ================================================ FILE: src/App.css ================================================ @import "tailwindcss"; @theme { /* Design tokens */ --color-text: #0f0f0f; --color-background: #fbfbfb; --color-background-ui: #da5893; --color-logo-primary: #faa2ca; --color-logo-stroke: #382731; --color-text-stroke: #f6f6f6; --color-mid-gray: #808080; } :root { /* Typography */ font-size: 15px; line-height: 24px; font-weight: 400; /* Colors - Light Theme */ /* --color-text: #0f0f0f; --color-background: #fbfbfb; --color-logo-primary: #FAA2CA; --color-logo-stroke: #382731; --color-text-stroke: #f6f6f6; */ /* Typography settings */ font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; -webkit-text-size-adjust: 100%; --scrollbar-thumb: color-mix(in srgb, var(--color-text), transparent 85%); --scrollbar-thumb-hover: color-mix( in srgb, var(--color-text), transparent 70% ); /* Apply colors */ color: var(--color-text); background-color: var(--color-background); } .container { margin: 0; padding-top: 10vh; display: flex; flex-direction: column; justify-content: center; text-align: center; } @media (prefers-color-scheme: dark) { :root { /* Colors - Dark Theme */ --color-text: #fbfbfb; --color-background: #2c2b29; --color-logo-primary: #f28cbb; --color-logo-stroke: #fad1ed; } } /* macOS - tint native overlay scrollbar thumb */ :root[data-platform="macos"] { scrollbar-color: var(--scrollbar-thumb) transparent; } /* Custom Scrollbar - only on Windows/Linux; macOS uses native overlay scrollbars */ :root:not([data-platform="macos"]) ::-webkit-scrollbar { width: 14px; height: 14px; } :root:not([data-platform="macos"]) ::-webkit-scrollbar-track { background: transparent; } :root:not([data-platform="macos"]) ::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb); border-radius: 20px; border: 3px solid transparent; border-right-width: 4px; border-left-width: 3px; background-clip: content-box; min-height: 32px; } :root:not([data-platform="macos"]) ::-webkit-scrollbar-thumb:hover { background-color: var(--scrollbar-thumb-hover); } @layer utilities { .text-stroke { -webkit-text-stroke: 2px var(--color-text-stroke); } } .logo-primary { fill: var(--color-logo-primary); } .logo-stroke { fill: var(--color-logo-stroke); stroke: var(--color-logo-stroke); stroke-width: 1; } ================================================ FILE: src/App.tsx ================================================ import { useEffect, useState, useRef } from "react"; import { toast, Toaster } from "sonner"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import { platform } from "@tauri-apps/plugin-os"; import { checkAccessibilityPermission, checkMicrophonePermission, } from "tauri-plugin-macos-permissions-api"; import { ModelStateEvent, RecordingErrorEvent } from "./lib/types/events"; import "./App.css"; import AccessibilityPermissions from "./components/AccessibilityPermissions"; import Footer from "./components/footer"; import Onboarding, { AccessibilityOnboarding } from "./components/onboarding"; import { Sidebar, SidebarSection, SECTIONS_CONFIG } from "./components/Sidebar"; import { useSettings } from "./hooks/useSettings"; import { useSettingsStore } from "./stores/settingsStore"; import { commands } from "@/bindings"; import { getLanguageDirection, initializeRTL } from "@/lib/utils/rtl"; type OnboardingStep = "accessibility" | "model" | "done"; const renderSettingsContent = (section: SidebarSection) => { const ActiveComponent = SECTIONS_CONFIG[section]?.component || SECTIONS_CONFIG.general.component; return ; }; function App() { const { t, i18n } = useTranslation(); const [onboardingStep, setOnboardingStep] = useState( null, ); // Track if this is a returning user who just needs to grant permissions // (vs a new user who needs full onboarding including model selection) const [isReturningUser, setIsReturningUser] = useState(false); const [currentSection, setCurrentSection] = useState("general"); const { settings, updateSetting } = useSettings(); const direction = getLanguageDirection(i18n.language); const refreshAudioDevices = useSettingsStore( (state) => state.refreshAudioDevices, ); const refreshOutputDevices = useSettingsStore( (state) => state.refreshOutputDevices, ); const hasCompletedPostOnboardingInit = useRef(false); useEffect(() => { checkOnboardingStatus(); }, []); // Initialize RTL direction when language changes useEffect(() => { initializeRTL(i18n.language); }, [i18n.language]); // Initialize Enigo, shortcuts, and refresh audio devices when main app loads useEffect(() => { if (onboardingStep === "done" && !hasCompletedPostOnboardingInit.current) { hasCompletedPostOnboardingInit.current = true; Promise.all([ commands.initializeEnigo(), commands.initializeShortcuts(), ]).catch((e) => { console.warn("Failed to initialize:", e); }); refreshAudioDevices(); refreshOutputDevices(); } }, [onboardingStep, refreshAudioDevices, refreshOutputDevices]); // Handle keyboard shortcuts for debug mode toggle useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Check for Ctrl+Shift+D (Windows/Linux) or Cmd+Shift+D (macOS) const isDebugShortcut = event.shiftKey && event.key.toLowerCase() === "d" && (event.ctrlKey || event.metaKey); if (isDebugShortcut) { event.preventDefault(); const currentDebugMode = settings?.debug_mode ?? false; updateSetting("debug_mode", !currentDebugMode); } }; // Add event listener when component mounts document.addEventListener("keydown", handleKeyDown); // Cleanup event listener when component unmounts return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [settings?.debug_mode, updateSetting]); // Listen for recording errors from the backend and show a toast useEffect(() => { const unlisten = listen("recording-error", (event) => { const { error_type, detail } = event.payload; if (error_type === "microphone_permission_denied") { const currentPlatform = platform(); const platformKey = `errors.micPermissionDenied.${currentPlatform}`; const description = t(platformKey, { defaultValue: t("errors.micPermissionDenied.generic"), }); toast.error(t("errors.micPermissionDeniedTitle"), { description }); } else { toast.error( t("errors.recordingFailed", { error: detail ?? "Unknown error" }), ); } }); return () => { unlisten.then((fn) => fn()); }; }, [t]); // Listen for model loading failures and show a toast useEffect(() => { const unlisten = listen("model-state-changed", (event) => { if (event.payload.event_type === "loading_failed") { toast.error( t("errors.modelLoadFailed", { model: event.payload.model_name || t("errors.modelLoadFailedUnknown"), }), { description: event.payload.error, }, ); } }); return () => { unlisten.then((fn) => fn()); }; }, [t]); const revealMainWindowForPermissions = async () => { try { await commands.showMainWindowCommand(); } catch (e) { console.warn("Failed to show main window for permission onboarding:", e); } }; const checkOnboardingStatus = async () => { try { // Check if they have any models available const result = await commands.hasAnyModelsAvailable(); const hasModels = result.status === "ok" && result.data; const currentPlatform = platform(); if (hasModels) { // Returning user - check if they need to grant permissions first setIsReturningUser(true); if (currentPlatform === "macos") { try { const [hasAccessibility, hasMicrophone] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); if (!hasAccessibility || !hasMicrophone) { await revealMainWindowForPermissions(); setOnboardingStep("accessibility"); return; } } catch (e) { console.warn("Failed to check macOS permissions:", e); // If we can't check, proceed to main app and let them fix it there } } if (currentPlatform === "windows") { try { const microphoneStatus = await commands.getWindowsMicrophonePermissionStatus(); if ( microphoneStatus.supported && microphoneStatus.overall_access === "denied" ) { await revealMainWindowForPermissions(); setOnboardingStep("accessibility"); return; } } catch (e) { console.warn("Failed to check Windows microphone permissions:", e); // If we can't check, proceed to main app and let them fix it there } } setOnboardingStep("done"); } else { // New user - start full onboarding setIsReturningUser(false); setOnboardingStep("accessibility"); } } catch (error) { console.error("Failed to check onboarding status:", error); setOnboardingStep("accessibility"); } }; const handleAccessibilityComplete = () => { // Returning users already have models, skip to main app // New users need to select a model setOnboardingStep(isReturningUser ? "done" : "model"); }; const handleModelSelected = () => { // Transition to main app - user has started a download setOnboardingStep("done"); }; // Still checking onboarding status if (onboardingStep === null) { return null; } if (onboardingStep === "accessibility") { return ; } if (onboardingStep === "model") { return ; } return (
{/* Main content area that takes remaining space */}
{/* Scrollable content area */}
{renderSettingsContent(currentSection)}
{/* Fixed footer at bottom */}
); } export default App; ================================================ FILE: src/bindings.ts ================================================ // This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually. /** user-defined commands **/ export const commands = { async changeBinding(id: string, binding: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_binding", { id, binding }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async resetBinding(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("reset_binding", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePttSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_ptt_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAudioFeedbackSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_audio_feedback_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAudioFeedbackVolumeSetting(volume: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_audio_feedback_volume_setting", { volume }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeSoundThemeSetting(theme: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_sound_theme_setting", { theme }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeStartHiddenSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_start_hidden_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAutostartSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_autostart_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeTranslateToEnglishSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_translate_to_english_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeSelectedLanguageSetting(language: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_selected_language_setting", { language }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeOverlayPositionSetting(position: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_overlay_position_setting", { position }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeDebugModeSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_debug_mode_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeWordCorrectionThresholdSetting(threshold: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_word_correction_threshold_setting", { threshold }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeExtraRecordingBufferSetting(ms: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_extra_recording_buffer_setting", { ms }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePasteMethodSetting(method: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_paste_method_setting", { method }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAvailableTypingTools() : Promise { return await TAURI_INVOKE("get_available_typing_tools"); }, async changeTypingToolSetting(tool: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_typing_tool_setting", { tool }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeExternalScriptPathSetting(path: string | null) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_external_script_path_setting", { path }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeClipboardHandlingSetting(handling: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_clipboard_handling_setting", { handling }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAutoSubmitSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_auto_submit_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAutoSubmitKeySetting(key: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_auto_submit_key_setting", { key }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePostProcessEnabledSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_post_process_enabled_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeExperimentalEnabledSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_experimental_enabled_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePostProcessBaseUrlSetting(providerId: string, baseUrl: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_post_process_base_url_setting", { providerId, baseUrl }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePostProcessApiKeySetting(providerId: string, apiKey: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_post_process_api_key_setting", { providerId, apiKey }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changePostProcessModelSetting(providerId: string, model: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_post_process_model_setting", { providerId, model }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setPostProcessProvider(providerId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_post_process_provider", { providerId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async fetchPostProcessModels(providerId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("fetch_post_process_models", { providerId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async addPostProcessPrompt(name: string, prompt: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("add_post_process_prompt", { name, prompt }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async updatePostProcessPrompt(id: string, name: string, prompt: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_post_process_prompt", { id, name, prompt }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async deletePostProcessPrompt(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("delete_post_process_prompt", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setPostProcessSelectedPrompt(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_post_process_selected_prompt", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async updateCustomWords(words: string[]) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_custom_words", { words }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Temporarily unregister a binding while the user is editing it in the UI. * This avoids firing the action while keys are being recorded. */ async suspendBinding(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("suspend_binding", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Re-register the binding after the user has finished editing. */ async resumeBinding(id: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("resume_binding", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeMuteWhileRecordingSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_mute_while_recording_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAppendTrailingSpaceSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_append_trailing_space_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeLazyStreamCloseSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_lazy_stream_close_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeAppLanguageSetting(language: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_app_language_setting", { language }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeUpdateChecksSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_update_checks_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Change the keyboard implementation with runtime switching. * This will unregister all shortcuts from the old implementation, * validate shortcuts for the new implementation (resetting invalid ones to defaults), * and register them with the new implementation. */ async changeKeyboardImplementationSetting(implementation: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_keyboard_implementation_setting", { implementation }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Get the current keyboard implementation */ async getKeyboardImplementation() : Promise { return await TAURI_INVOKE("get_keyboard_implementation"); }, async changeShowTrayIconSetting(enabled: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_show_tray_icon_setting", { enabled }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeWhisperAcceleratorSetting(accelerator: WhisperAcceleratorSetting) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_whisper_accelerator_setting", { accelerator }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async changeOrtAcceleratorSetting(accelerator: OrtAcceleratorSetting) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("change_ort_accelerator_setting", { accelerator }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Return which ORT accelerators are compiled into this build. */ async getAvailableAccelerators() : Promise { return await TAURI_INVOKE("get_available_accelerators"); }, /** * Start key recording mode */ async startHandyKeysRecording(bindingId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("start_handy_keys_recording", { bindingId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Stop key recording mode */ async stopHandyKeysRecording() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("stop_handy_keys_recording") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async triggerUpdateCheck() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("trigger_update_check") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async showMainWindowCommand() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("show_main_window_command") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async cancelOperation() : Promise { await TAURI_INVOKE("cancel_operation"); }, async getAppDirPath() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_app_dir_path") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAppSettings() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_app_settings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getDefaultSettings() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_default_settings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getLogDirPath() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_log_dir_path") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setLogLevel(level: LogLevel) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_log_level", { level }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async openRecordingsFolder() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("open_recordings_folder") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async openLogDir() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("open_log_dir") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async openAppDataDir() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("open_app_data_dir") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Check if Apple Intelligence is available on this device. * Called by the frontend when the user selects Apple Intelligence provider. */ async checkAppleIntelligenceAvailable() : Promise { return await TAURI_INVOKE("check_apple_intelligence_available"); }, /** * Try to initialize Enigo (keyboard/mouse simulation). * On macOS, this will return an error if accessibility permissions are not granted. */ async initializeEnigo() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("initialize_enigo") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Initialize keyboard shortcuts. * On macOS, this should be called after accessibility permissions are granted. * This is idempotent - calling it multiple times is safe. */ async initializeShortcuts() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("initialize_shortcuts") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAvailableModels() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_models") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getModelInfo(modelId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_model_info", { modelId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async downloadModel(modelId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("download_model", { modelId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async deleteModel(modelId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("delete_model", { modelId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async cancelDownload(modelId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("cancel_download", { modelId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setActiveModel(modelId: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_active_model", { modelId }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getCurrentModel() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_current_model") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getTranscriptionModelStatus() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_transcription_model_status") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async isModelLoading() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("is_model_loading") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async hasAnyModelsAvailable() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("has_any_models_available") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async hasAnyModelsOrDownloads() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("has_any_models_or_downloads") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async updateMicrophoneMode(alwaysOn: boolean) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_microphone_mode", { alwaysOn }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getMicrophoneMode() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_microphone_mode") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getWindowsMicrophonePermissionStatus() : Promise { return await TAURI_INVOKE("get_windows_microphone_permission_status"); }, async openMicrophonePrivacySettings() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("open_microphone_privacy_settings") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAvailableMicrophones() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_microphones") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setSelectedMicrophone(deviceName: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_selected_microphone", { deviceName }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getSelectedMicrophone() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_selected_microphone") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAvailableOutputDevices() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_available_output_devices") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async setSelectedOutputDevice(deviceName: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_selected_output_device", { deviceName }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getSelectedOutputDevice() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_selected_output_device") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async playTestSound(soundType: string) : Promise { await TAURI_INVOKE("play_test_sound", { soundType }); }, async checkCustomSounds() : Promise { return await TAURI_INVOKE("check_custom_sounds"); }, async setClamshellMicrophone(deviceName: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("set_clamshell_microphone", { deviceName }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getClamshellMicrophone() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_clamshell_microphone") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async isRecording() : Promise { return await TAURI_INVOKE("is_recording"); }, async setModelUnloadTimeout(timeout: ModelUnloadTimeout) : Promise { await TAURI_INVOKE("set_model_unload_timeout", { timeout }); }, async getModelLoadStatus() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_model_load_status") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async unloadModelManually() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("unload_model_manually") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getHistoryEntries() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_history_entries") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async toggleHistoryEntrySaved(id: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("toggle_history_entry_saved", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async getAudioFilePath(fileName: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("get_audio_file_path", { fileName }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async deleteHistoryEntry(id: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("delete_history_entry", { id }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async updateHistoryLimit(limit: number) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_history_limit", { limit }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, async updateRecordingRetentionPeriod(period: string) : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("update_recording_retention_period", { period }) }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } }, /** * Checks if the Mac is a laptop by detecting battery presence * * This uses pmset to check for battery information. * Returns true if a battery is detected (laptop), false otherwise (desktop) */ async isLaptop() : Promise> { try { return { status: "ok", data: await TAURI_INVOKE("is_laptop") }; } catch (e) { if(e instanceof Error) throw e; else return { status: "error", error: e as any }; } } } /** user-defined events **/ /** user-defined constants **/ /** user-defined types **/ export 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 } export type AudioDevice = { index: string; name: string; is_default: boolean } export type AutoSubmitKey = "enter" | "ctrl_enter" | "cmd_enter" export type AvailableAccelerators = { whisper: string[]; ort: string[] } export type BindingResponse = { success: boolean; binding: ShortcutBinding | null; error: string | null } export type ClipboardHandling = "dont_modify" | "copy_to_clipboard" export type CustomSounds = { start: boolean; stop: boolean } export type EngineType = "Whisper" | "Parakeet" | "Moonshine" | "MoonshineStreaming" | "SenseVoice" | "GigaAM" | "Canary" export 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 } /** * Result of changing keyboard implementation */ export type ImplementationChangeResult = { success: boolean; /** * List of binding IDs that were reset to defaults due to incompatibility */ reset_bindings: string[] } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" export 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 } export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null } export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15" export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm" export type OverlayPosition = "none" | "top" | "bottom" export type PasteMethod = "ctrl_v" | "direct" | "none" | "shift_insert" | "ctrl_shift_v" | "external_script" export type PermissionAccess = "allowed" | "denied" | "unknown" export type PostProcessProvider = { id: string; label: string; base_url: string; allow_base_url_edit?: boolean; models_endpoint?: string | null; supports_structured_output?: boolean } export type RecordingRetentionPeriod = "never" | "preserve_limit" | "days_3" | "weeks_2" | "months_3" export type ShortcutBinding = { id: string; name: string; description: string; default_binding: string; current_binding: string } export type SoundTheme = "marimba" | "pop" | "custom" export type TypingTool = "auto" | "wtype" | "kwtype" | "dotool" | "ydotool" | "xdotool" export type WhisperAcceleratorSetting = "auto" | "cpu" | "gpu" export type WindowsMicrophonePermissionStatus = { supported: boolean; overall_access: PermissionAccess; device_access: PermissionAccess; app_access: PermissionAccess; desktop_app_access: PermissionAccess } /** tauri-specta globals **/ import { invoke as TAURI_INVOKE, Channel as TAURI_CHANNEL, } from "@tauri-apps/api/core"; import * as TAURI_API_EVENT from "@tauri-apps/api/event"; import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow"; type __EventObj__ = { listen: ( cb: TAURI_API_EVENT.EventCallback, ) => ReturnType>; once: ( cb: TAURI_API_EVENT.EventCallback, ) => ReturnType>; emit: null extends T ? (payload?: T) => ReturnType : (payload: T) => ReturnType; }; export type Result = | { status: "ok"; data: T } | { status: "error"; error: E }; function __makeEvents__>( mappings: Record, ) { return new Proxy( {} as unknown as { [K in keyof T]: __EventObj__ & { (handle: __WebviewWindow__): __EventObj__; }; }, { get: (_, event) => { const name = mappings[event as keyof T]; return new Proxy((() => {}) as any, { apply: (_, __, [window]: [__WebviewWindow__]) => ({ listen: (arg: any) => window.listen(name, arg), once: (arg: any) => window.once(name, arg), emit: (arg: any) => window.emit(name, arg), }), get: (_, command: keyof __EventObj__) => { switch (command) { case "listen": return (arg: any) => TAURI_API_EVENT.listen(name, arg); case "once": return (arg: any) => TAURI_API_EVENT.once(name, arg); case "emit": return (arg: any) => TAURI_API_EVENT.emit(name, arg); } }, }); }, }, ); } ================================================ FILE: src/components/AccessibilityPermissions.tsx ================================================ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { type } from "@tauri-apps/plugin-os"; import { checkAccessibilityPermission, requestAccessibilityPermission, } from "tauri-plugin-macos-permissions-api"; // Define permission state type type PermissionState = "request" | "verify" | "granted"; // Define button configuration type interface ButtonConfig { text: string; className: string; } const AccessibilityPermissions: React.FC = () => { const { t } = useTranslation(); const [hasAccessibility, setHasAccessibility] = useState(false); const [permissionState, setPermissionState] = useState("request"); // Accessibility permissions are only required on macOS const isMacOS = type() === "macos"; // Check permissions without requesting const checkPermissions = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); setHasAccessibility(hasPermissions); setPermissionState(hasPermissions ? "granted" : "verify"); return hasPermissions; }; // Handle the unified button action based on current state const handleButtonClick = async (): Promise => { if (permissionState === "request") { try { await requestAccessibilityPermission(); // After system prompt, transition to verification state setPermissionState("verify"); } catch (error) { console.error("Error requesting permissions:", error); setPermissionState("verify"); } } else if (permissionState === "verify") { // State is "verify" - check if permission was granted await checkPermissions(); } }; // On app boot - check permissions (only on macOS) useEffect(() => { if (!isMacOS) return; const initialSetup = async (): Promise => { const hasPermissions: boolean = await checkAccessibilityPermission(); setHasAccessibility(hasPermissions); setPermissionState(hasPermissions ? "granted" : "request"); }; initialSetup(); }, [isMacOS]); // Skip rendering on non-macOS platforms or if permission is already granted if (!isMacOS || hasAccessibility) { return null; } // Configure button text and style based on state const buttonConfig: Record = { request: { text: t("accessibility.openSettings"), className: "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", }, verify: { text: t("accessibility.openSettings"), className: "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", }, granted: null, }; const config = buttonConfig[permissionState] as ButtonConfig; return (

{t("accessibility.permissionsDescription")}

); }; export default AccessibilityPermissions; ================================================ FILE: src/components/Sidebar.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Cog, FlaskConical, History, Info, Sparkles, Cpu } from "lucide-react"; import HandyTextLogo from "./icons/HandyTextLogo"; import HandyHand from "./icons/HandyHand"; import { useSettings } from "../hooks/useSettings"; import { GeneralSettings, AdvancedSettings, HistorySettings, DebugSettings, AboutSettings, PostProcessingSettings, ModelsSettings, } from "./settings"; export type SidebarSection = keyof typeof SECTIONS_CONFIG; interface IconProps { width?: number | string; height?: number | string; size?: number | string; className?: string; [key: string]: any; } interface SectionConfig { labelKey: string; icon: React.ComponentType; component: React.ComponentType; enabled: (settings: any) => boolean; } export const SECTIONS_CONFIG = { general: { labelKey: "sidebar.general", icon: HandyHand, component: GeneralSettings, enabled: () => true, }, models: { labelKey: "sidebar.models", icon: Cpu, component: ModelsSettings, enabled: () => true, }, advanced: { labelKey: "sidebar.advanced", icon: Cog, component: AdvancedSettings, enabled: () => true, }, postprocessing: { labelKey: "sidebar.postProcessing", icon: Sparkles, component: PostProcessingSettings, enabled: (settings) => settings?.post_process_enabled ?? false, }, history: { labelKey: "sidebar.history", icon: History, component: HistorySettings, enabled: () => true, }, debug: { labelKey: "sidebar.debug", icon: FlaskConical, component: DebugSettings, enabled: (settings) => settings?.debug_mode ?? false, }, about: { labelKey: "sidebar.about", icon: Info, component: AboutSettings, enabled: () => true, }, } as const satisfies Record; interface SidebarProps { activeSection: SidebarSection; onSectionChange: (section: SidebarSection) => void; } export const Sidebar: React.FC = ({ activeSection, onSectionChange, }) => { const { t } = useTranslation(); const { settings } = useSettings(); const availableSections = Object.entries(SECTIONS_CONFIG) .filter(([_, config]) => config.enabled(settings)) .map(([id, config]) => ({ id: id as SidebarSection, ...config })); return (
{availableSections.map((section) => { const Icon = section.icon; const isActive = activeSection === section.id; return (
onSectionChange(section.id)} >

{t(section.labelKey)}

); })}
); }; ================================================ FILE: src/components/footer/Footer.tsx ================================================ import React, { useState, useEffect } from "react"; import { getVersion } from "@tauri-apps/api/app"; import ModelSelector from "../model-selector"; import UpdateChecker from "../update-checker"; const Footer: React.FC = () => { const [version, setVersion] = useState(""); useEffect(() => { const fetchVersion = async () => { try { const appVersion = await getVersion(); setVersion(appVersion); } catch (error) { console.error("Failed to get app version:", error); setVersion("0.1.2"); } }; fetchVersion(); }, []); return (
{/* Update Status */}
{/* eslint-disable-next-line i18next/no-literal-string */} v{version}
); }; export default Footer; ================================================ FILE: src/components/footer/index.ts ================================================ export { default } from "./Footer"; ================================================ FILE: src/components/icons/CancelIcon.tsx ================================================ import React from "react"; interface CancelIconProps { width?: number; height?: number; color?: string; className?: string; } const CancelIcon: React.FC = ({ width = 24, height = 24, color = "#FAA2CA", className = "", }) => { return ( ); }; export default CancelIcon; ================================================ FILE: src/components/icons/HandyHand.tsx ================================================ const HandyHand = ({ width, height, }: { width?: number | string; height?: number | string; }) => ( ); export default HandyHand; ================================================ FILE: src/components/icons/HandyTextLogo.tsx ================================================ import React from "react"; const HandyTextLogo = ({ width, height, className, }: { width?: number; height?: number; className?: string; }) => { return ( ); }; export default HandyTextLogo; ================================================ FILE: src/components/icons/MicrophoneIcon.tsx ================================================ import React from "react"; interface MicrophoneIconProps { width?: number; height?: number; color?: string; className?: string; } const MicrophoneIcon: React.FC = ({ width = 24, height = 24, color = "#FAA2CA", className = "", }) => { return ( ); }; export default MicrophoneIcon; ================================================ FILE: src/components/icons/ResetIcon.tsx ================================================ import React from "react"; interface ResetIconProps { width?: number; height?: number; color?: string; className?: string; } const ResetIcon: React.FC = ({ width = 20, height = 20, className = "", }) => { return ( ); }; export default ResetIcon; ================================================ FILE: src/components/icons/TranscriptionIcon.tsx ================================================ import React from "react"; interface TranscriptionIconProps { width?: number; height?: number; color?: string; className?: string; } const TranscriptionIcon: React.FC = ({ width = 24, height = 24, color = "#FAA2CA", className = "", }) => { return ( ); }; export default TranscriptionIcon; ================================================ FILE: src/components/icons/index.ts ================================================ export { default as MicrophoneIcon } from "./MicrophoneIcon"; export { default as TranscriptionIcon } from "./TranscriptionIcon"; export { default as CancelIcon } from "./CancelIcon"; ================================================ FILE: src/components/model-selector/DownloadProgressDisplay.tsx ================================================ import React from "react"; import { ProgressBar, ProgressData } from "../shared"; interface DownloadProgress { model_id: string; downloaded: number; total: number; percentage: number; } interface DownloadStats { startTime: number; lastUpdate: number; totalDownloaded: number; speed: number; } interface DownloadProgressDisplayProps { downloadProgress: Record; downloadStats: Record; className?: string; } const DownloadProgressDisplay: React.FC = ({ downloadProgress, downloadStats, className = "", }) => { const progressValues = Object.values(downloadProgress); if (progressValues.length === 0) { return null; } const progressData: ProgressData[] = progressValues.map((progress) => { const stats = downloadStats[progress.model_id]; return { id: progress.model_id, percentage: progress.percentage, speed: stats?.speed, }; }); return ( ); }; export default DownloadProgressDisplay; ================================================ FILE: src/components/model-selector/ModelDropdown.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import type { ModelInfo } from "@/bindings"; import { getTranslatedModelName, getTranslatedModelDescription, } from "../../lib/utils/modelTranslation"; interface ModelDropdownProps { models: ModelInfo[]; currentModelId: string; onModelSelect: (modelId: string) => void; } const ModelDropdown: React.FC = ({ models, currentModelId, onModelSelect, }) => { const { t } = useTranslation(); const downloadedModels = models.filter((m) => m.is_downloaded); const handleModelClick = (modelId: string) => { onModelSelect(modelId); }; return (
{downloadedModels.length > 0 ? (
{downloadedModels.map((model) => (
handleModelClick(model.id)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleModelClick(model.id); } }} tabIndex={0} role="button" className={`w-full px-3 py-2 text-start hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${ currentModelId === model.id ? "bg-logo-primary/10 text-logo-primary" : "" }`} >
{getTranslatedModelName(model, t)} {model.is_custom && ( {t("modelSelector.custom")} )}
{getTranslatedModelDescription(model, t)}
{currentModelId === model.id && (
{t("modelSelector.active")}
)}
))}
) : (
{t("modelSelector.noModelsAvailable")}
)}
); }; export default ModelDropdown; ================================================ FILE: src/components/model-selector/ModelSelector.tsx ================================================ import React, { useState, useRef, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import { commands } from "@/bindings"; import { getTranslatedModelName } from "../../lib/utils/modelTranslation"; import { useModelStore } from "../../stores/modelStore"; import ModelStatusButton from "./ModelStatusButton"; import ModelDropdown from "./ModelDropdown"; import DownloadProgressDisplay from "./DownloadProgressDisplay"; import { ModelStateEvent } from "@/lib/types/events"; type ModelStatus = | "ready" | "loading" | "downloading" | "extracting" | "error" | "unloaded" | "none"; interface ModelSelectorProps { onError?: (error: string) => void; } const ModelSelector: React.FC = ({ onError }) => { const { t } = useTranslation(); const { models, currentModel, downloadProgress, downloadStats, extractingModels, selectModel, } = useModelStore(); const [modelStatus, setModelStatus] = useState("unloaded"); const [modelError, setModelError] = useState(null); const [showModelDropdown, setShowModelDropdown] = useState(false); // Track pending model switch for optimistic display const [pendingModelId, setPendingModelId] = useState(null); const dropdownRef = useRef(null); const displayModelId = pendingModelId || currentModel; // Check model status when currentModel changes useEffect(() => { const checkStatus = async () => { if (currentModel) { try { const statusResult = await commands.getTranscriptionModelStatus(); if (statusResult.status === "ok") { setModelStatus( statusResult.data === currentModel ? "ready" : "unloaded", ); } } catch { setModelStatus("error"); setModelError("Failed to check model status"); } } else { setModelStatus("none"); } }; checkStatus(); }, [currentModel]); useEffect(() => { // Listen for model loading lifecycle events const modelStateUnlisten = listen( "model-state-changed", (event) => { const { event_type, error } = event.payload; switch (event_type) { case "loading_started": setModelStatus("loading"); setModelError(null); break; case "loading_completed": setModelStatus("ready"); setModelError(null); setPendingModelId(null); break; case "loading_failed": setModelStatus("error"); setModelError(error || "Failed to load model"); setPendingModelId(null); break; case "unloaded": setModelStatus("unloaded"); setModelError(null); break; } }, ); // Auto-select model when download completes (fires after extraction too) const downloadCompleteUnlisten = listen( "model-download-complete", (event) => { const modelId = event.payload; setTimeout(async () => { try { const isRecording = await commands.isRecording(); if (!isRecording) { setPendingModelId(modelId); setModelError(null); setShowModelDropdown(false); const success = await selectModel(modelId); if (!success) { setPendingModelId(null); } } } catch { // Ignore errors in auto-select } }, 500); }, ); // Click outside to close dropdown const handleClickOutside = (event: MouseEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setShowModelDropdown(false); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); modelStateUnlisten.then((fn) => fn()); downloadCompleteUnlisten.then((fn) => fn()); }; }, [selectModel]); const handleModelSelect = async (modelId: string) => { setPendingModelId(modelId); setModelError(null); setShowModelDropdown(false); const success = await selectModel(modelId); if (!success) { setPendingModelId(null); setModelStatus("error"); setModelError("Failed to switch model"); onError?.("Failed to switch model"); } }; const getModelDisplayText = (): string => { const extractingKeys = Object.keys(extractingModels); if (extractingKeys.length > 0) { if (extractingKeys.length === 1) { const modelId = extractingKeys[0]; const model = models.find((m) => m.id === modelId); const modelName = model ? getTranslatedModelName(model, t) : t("modelSelector.extractingGeneric").replace("...", ""); return t("modelSelector.extracting", { modelName }); } else { return t("modelSelector.extractingMultiple", { count: extractingKeys.length, }); } } const progressValues = Object.values(downloadProgress); if (progressValues.length > 0) { if (progressValues.length === 1) { const progress = progressValues[0]; const percentage = Math.max( 0, Math.min(100, Math.round(progress.percentage)), ); return t("modelSelector.downloading", { percentage }); } else { return t("modelSelector.downloadingMultiple", { count: progressValues.length, }); } } const currentModelInfo = models.find((m) => m.id === displayModelId); switch (modelStatus) { case "ready": return currentModelInfo ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelReady"); case "loading": return currentModelInfo ? t("modelSelector.loading", { modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.loadingGeneric"); case "extracting": return currentModelInfo ? t("modelSelector.extracting", { modelName: getTranslatedModelName(currentModelInfo, t), }) : t("modelSelector.extractingGeneric"); case "error": return modelError || t("modelSelector.modelError"); case "unloaded": return currentModelInfo ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); case "none": return t("modelSelector.noModelDownloadRequired"); default: return currentModelInfo ? getTranslatedModelName(currentModelInfo, t) : t("modelSelector.modelUnloaded"); } }; // Derive display status from model status + store state const getDisplayStatus = (): ModelStatus => { if (Object.keys(extractingModels).length > 0) return "extracting"; if (Object.keys(downloadProgress).length > 0) return "downloading"; return modelStatus; }; return ( <> {/* Model Status and Switcher */}
setShowModelDropdown(!showModelDropdown)} /> {/* Model Dropdown */} {showModelDropdown && ( )}
{/* Download Progress Bar for Models */} ); }; export default ModelSelector; ================================================ FILE: src/components/model-selector/ModelStatusButton.tsx ================================================ import React from "react"; type ModelStatus = | "ready" | "loading" | "downloading" | "extracting" | "error" | "unloaded" | "none"; interface ModelStatusButtonProps { status: ModelStatus; displayText: string; isDropdownOpen: boolean; onClick: () => void; className?: string; } const ModelStatusButton: React.FC = ({ status, displayText, isDropdownOpen, onClick, className = "", }) => { const getStatusColor = (status: ModelStatus): string => { switch (status) { case "ready": return "bg-green-400"; case "loading": return "bg-yellow-400 animate-pulse"; case "downloading": return "bg-logo-primary animate-pulse"; case "extracting": return "bg-orange-400 animate-pulse"; case "error": return "bg-red-400"; case "unloaded": return "bg-mid-gray/60"; case "none": return "bg-red-400"; default: return "bg-mid-gray/60"; } }; return ( ); }; export default ModelStatusButton; ================================================ FILE: src/components/model-selector/index.ts ================================================ export { default } from "./ModelSelector"; export { default as ModelStatusButton } from "./ModelStatusButton"; export { default as ModelDropdown } from "./ModelDropdown"; export { default as DownloadProgressDisplay } from "./DownloadProgressDisplay"; ================================================ FILE: src/components/onboarding/AccessibilityOnboarding.tsx ================================================ import { useEffect, useState, useCallback, useRef } from "react"; import { useTranslation } from "react-i18next"; import { platform } from "@tauri-apps/plugin-os"; import { checkAccessibilityPermission, requestAccessibilityPermission, checkMicrophonePermission, requestMicrophonePermission, } from "tauri-plugin-macos-permissions-api"; import { toast } from "sonner"; import { commands } from "@/bindings"; import { useSettingsStore } from "@/stores/settingsStore"; import HandyTextLogo from "../icons/HandyTextLogo"; import { Keyboard, Mic, Check, Loader2 } from "lucide-react"; interface AccessibilityOnboardingProps { onComplete: () => void; } type PermissionStatus = "checking" | "needed" | "waiting" | "granted"; type PermissionPlatform = "macos" | "windows" | "other"; interface PermissionsState { accessibility: PermissionStatus; microphone: PermissionStatus; } const AccessibilityOnboarding: React.FC = ({ onComplete, }) => { const { t } = useTranslation(); const refreshAudioDevices = useSettingsStore( (state) => state.refreshAudioDevices, ); const refreshOutputDevices = useSettingsStore( (state) => state.refreshOutputDevices, ); const [permissionPlatform, setPermissionPlatform] = useState(null); const [permissions, setPermissions] = useState({ accessibility: "checking", microphone: "checking", }); const pollingRef = useRef | null>(null); const timeoutRef = useRef | null>(null); const errorCountRef = useRef(0); const MAX_POLLING_ERRORS = 3; const isMacOS = permissionPlatform === "macos"; const isWindows = permissionPlatform === "windows"; const showMicrophonePermission = isMacOS || isWindows; const showAccessibilityPermission = isMacOS; const allGranted = isMacOS ? permissions.accessibility === "granted" && permissions.microphone === "granted" : isWindows ? permissions.microphone === "granted" : true; const completeOnboarding = useCallback(async () => { await Promise.all([refreshAudioDevices(), refreshOutputDevices()]); timeoutRef.current = setTimeout(() => onComplete(), 300); }, [onComplete, refreshAudioDevices, refreshOutputDevices]); const hasWindowsMicrophoneAccess = useCallback(async (): Promise => { const microphoneStatus = await commands.getWindowsMicrophonePermissionStatus(); if (!microphoneStatus.supported) { return true; } return microphoneStatus.overall_access !== "denied"; }, []); // Check platform and permission status on mount useEffect(() => { const currentPlatform = platform(); const nextPlatform: PermissionPlatform = currentPlatform === "macos" ? "macos" : currentPlatform === "windows" ? "windows" : "other"; setPermissionPlatform(nextPlatform); // Skip immediately on unsupported platforms if (nextPlatform === "other") { onComplete(); return; } const checkInitial = async () => { if (nextPlatform === "macos") { try { const [accessibilityGranted, microphoneGranted] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); // If accessibility is granted, initialize Enigo and shortcuts if (accessibilityGranted) { try { await Promise.all([ commands.initializeEnigo(), commands.initializeShortcuts(), ]); } catch (e) { console.warn("Failed to initialize after permission grant:", e); } } const newState: PermissionsState = { accessibility: accessibilityGranted ? "granted" : "needed", microphone: microphoneGranted ? "granted" : "needed", }; setPermissions(newState); if (accessibilityGranted && microphoneGranted) { await completeOnboarding(); } } catch (error) { console.error("Failed to check macOS permissions:", error); toast.error(t("onboarding.permissions.errors.checkFailed")); setPermissions({ accessibility: "needed", microphone: "needed", }); } return; } try { const microphoneGranted = await hasWindowsMicrophoneAccess(); setPermissions({ accessibility: "granted", microphone: microphoneGranted ? "granted" : "needed", }); if (microphoneGranted) { await completeOnboarding(); } } catch (error) { console.warn("Failed to check Windows microphone permissions:", error); setPermissions({ accessibility: "granted", microphone: "granted", }); await completeOnboarding(); } }; checkInitial(); }, [completeOnboarding, hasWindowsMicrophoneAccess, onComplete, t]); // Polling for permissions after user clicks a button const startPolling = useCallback(() => { if (pollingRef.current || permissionPlatform === null) return; pollingRef.current = setInterval(async () => { try { if (permissionPlatform === "windows") { const microphoneGranted = await hasWindowsMicrophoneAccess(); if (microphoneGranted) { setPermissions((prev) => ({ ...prev, microphone: "granted" })); if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } await completeOnboarding(); } errorCountRef.current = 0; return; } const [accessibilityGranted, microphoneGranted] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), ]); setPermissions((prev) => { const newState = { ...prev }; if (accessibilityGranted && prev.accessibility !== "granted") { newState.accessibility = "granted"; // Initialize Enigo and shortcuts when accessibility is granted Promise.all([ commands.initializeEnigo(), commands.initializeShortcuts(), ]).catch((e) => { console.warn("Failed to initialize after permission grant:", e); }); } if (microphoneGranted && prev.microphone !== "granted") { newState.microphone = "granted"; } return newState; }); // If both granted, stop polling, refresh audio devices, and proceed if (accessibilityGranted && microphoneGranted) { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } await completeOnboarding(); } // Reset error count on success errorCountRef.current = 0; } catch (error) { console.error("Error checking permissions:", error); errorCountRef.current += 1; if (errorCountRef.current >= MAX_POLLING_ERRORS) { // Stop polling after too many consecutive errors if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; } toast.error(t("onboarding.permissions.errors.checkFailed")); } } }, 1000); }, [completeOnboarding, hasWindowsMicrophoneAccess, permissionPlatform, t]); // Cleanup polling and timeouts on unmount useEffect(() => { return () => { if (pollingRef.current) { clearInterval(pollingRef.current); } if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); const handleGrantAccessibility = async () => { try { await requestAccessibilityPermission(); setPermissions((prev) => ({ ...prev, accessibility: "waiting" })); startPolling(); } catch (error) { console.error("Failed to request accessibility permission:", error); toast.error(t("onboarding.permissions.errors.requestFailed")); } }; const handleGrantMicrophone = async () => { try { if (isWindows) { await commands.openMicrophonePrivacySettings(); } else { await requestMicrophonePermission(); } setPermissions((prev) => ({ ...prev, microphone: "waiting" })); startPolling(); } catch (error) { console.error("Failed to request microphone permission:", error); toast.error(t("onboarding.permissions.errors.requestFailed")); } }; const isChecking = permissionPlatform === null || (isMacOS && permissions.accessibility === "checking" && permissions.microphone === "checking") || (isWindows && permissions.microphone === "checking"); // Still checking platform/initial permissions if (isChecking) { return (
); } // All permissions granted - show success briefly if (allGranted) { return (

{t("onboarding.permissions.allGranted")}

); } // Show permissions request screen return (

{t("onboarding.permissions.title")}

{t("onboarding.permissions.description")}

{/* Microphone Permission Card */} {showMicrophonePermission && (

{t("onboarding.permissions.microphone.title")}

{t("onboarding.permissions.microphone.description")}

{permissions.microphone === "granted" ? (
{t("onboarding.permissions.granted")}
) : permissions.microphone === "waiting" ? (
{t("onboarding.permissions.waiting")}
) : ( )}
)} {/* Accessibility Permission Card */} {showAccessibilityPermission && (

{t("onboarding.permissions.accessibility.title")}

{t("onboarding.permissions.accessibility.description")}

{permissions.accessibility === "granted" ? (
{t("onboarding.permissions.granted")}
) : permissions.accessibility === "waiting" ? (
{t("onboarding.permissions.waiting")}
) : ( )}
)}
); }; export default AccessibilityOnboarding; ================================================ FILE: src/components/onboarding/ModelCard.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Check, Download, Globe, Languages, Loader2, Trash2, } from "lucide-react"; import type { ModelInfo } from "@/bindings"; import { formatModelSize } from "../../lib/utils/format"; import { getTranslatedModelDescription, getTranslatedModelName, } from "../../lib/utils/modelTranslation"; import { LANGUAGES } from "../../lib/constants/languages"; import Badge from "../ui/Badge"; import { Button } from "../ui/Button"; // Get display text for model's language support const getLanguageDisplayText = ( supportedLanguages: string[], t: (key: string, options?: Record) => string, ): string => { if (supportedLanguages.length === 1) { const langCode = supportedLanguages[0]; const langName = LANGUAGES.find((l) => l.value === langCode)?.label || langCode; return t("modelSelector.capabilities.languageOnly", { language: langName }); } return t("modelSelector.capabilities.multiLanguage"); }; export type ModelCardStatus = | "downloadable" | "downloading" | "extracting" | "switching" | "active" | "available"; interface ModelCardProps { model: ModelInfo; variant?: "default" | "featured"; status?: ModelCardStatus; disabled?: boolean; className?: string; onSelect: (modelId: string) => void; onDownload?: (modelId: string) => void; onDelete?: (modelId: string) => void; onCancel?: (modelId: string) => void; downloadProgress?: number; downloadSpeed?: number; // MB/s showRecommended?: boolean; } const ModelCard: React.FC = ({ model, variant = "default", status = "downloadable", disabled = false, className = "", onSelect, onDownload, onDelete, onCancel, downloadProgress, downloadSpeed, showRecommended = true, }) => { const { t } = useTranslation(); const isFeatured = variant === "featured"; const isClickable = status === "available" || status === "active" || status === "downloadable"; // Get translated model name and description const displayName = getTranslatedModelName(model, t); const displayDescription = getTranslatedModelDescription(model, t); const baseClasses = "flex flex-col rounded-xl px-4 py-3 gap-2 text-left transition-all duration-200"; const getVariantClasses = () => { if (status === "active") { return "border-2 border-logo-primary/50 bg-logo-primary/10"; } if (isFeatured) { return "border-2 border-logo-primary/25 bg-logo-primary/5"; } return "border-2 border-mid-gray/20"; }; const getInteractiveClasses = () => { if (!isClickable) return ""; if (disabled) return "opacity-50 cursor-not-allowed"; 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"; }; const handleClick = () => { if (!isClickable || disabled) return; if (status === "downloadable" && onDownload) { onDownload(model.id); } else { onSelect(model.id); } }; const handleDelete = (e: React.MouseEvent) => { e.stopPropagation(); onDelete?.(model.id); }; return (
{ if (e.key === "Enter" && isClickable) handleClick(); }} role={isClickable ? "button" : undefined} tabIndex={isClickable ? 0 : undefined} className={[ baseClasses, getVariantClasses(), getInteractiveClasses(), className, ] .filter(Boolean) .join(" ")} > {/* Top section: name/description + score bars */}

{displayName}

{showRecommended && model.is_recommended && ( {t("onboarding.recommended")} )} {status === "active" && ( {t("modelSelector.active")} )} {model.is_custom && ( {t("modelSelector.custom")} )} {status === "switching" && ( {t("modelSelector.switching")} )}

{displayDescription}

{(model.accuracy_score > 0 || model.speed_score > 0) && (

{t("onboarding.modelCard.accuracy")}

{t("onboarding.modelCard.speed")}

)}

{/* Bottom row: tags + action buttons (full width) */}
{model.supported_languages.length > 0 && (
{getLanguageDisplayText(model.supported_languages, t)}
)} {model.supports_translation && (
{t("modelSelector.capabilities.translate")}
)} {status === "downloadable" && ( {formatModelSize(Number(model.size_mb))} )} {onDelete && (status === "available" || status === "active") && ( )}
{/* Download/extract progress */} {status === "downloading" && downloadProgress !== undefined && (
{t("modelSelector.downloading", { percentage: Math.round(downloadProgress), })}
{downloadSpeed !== undefined && downloadSpeed > 0 && ( {t("modelSelector.downloadSpeed", { speed: downloadSpeed.toFixed(1), })} )} {onCancel && ( )}
)} {status === "extracting" && (

{t("modelSelector.extractingGeneric")}

)}
); }; export default ModelCard; ================================================ FILE: src/components/onboarding/Onboarding.tsx ================================================ import React, { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import type { ModelInfo } from "@/bindings"; import type { ModelCardStatus } from "./ModelCard"; import ModelCard from "./ModelCard"; import HandyTextLogo from "../icons/HandyTextLogo"; import { useModelStore } from "../../stores/modelStore"; interface OnboardingProps { onModelSelected: () => void; } const Onboarding: React.FC = ({ onModelSelected }) => { const { t } = useTranslation(); const { models, downloadModel, selectModel, downloadingModels, extractingModels, downloadProgress, downloadStats, } = useModelStore(); const [selectedModelId, setSelectedModelId] = useState(null); const isDownloading = selectedModelId !== null; // Watch for the selected model to finish downloading + extracting useEffect(() => { if (!selectedModelId) return; const model = models.find((m) => m.id === selectedModelId); const stillDownloading = selectedModelId in downloadingModels; const stillExtracting = selectedModelId in extractingModels; if (model?.is_downloaded && !stillDownloading && !stillExtracting) { // Model is ready — select it and transition selectModel(selectedModelId).then((success) => { if (success) { onModelSelected(); } else { toast.error(t("onboarding.errors.selectModel")); setSelectedModelId(null); } }); } }, [ selectedModelId, models, downloadingModels, extractingModels, selectModel, onModelSelected, ]); const handleDownloadModel = async (modelId: string) => { setSelectedModelId(modelId); const success = await downloadModel(modelId); if (!success) { toast.error(t("onboarding.downloadFailed")); setSelectedModelId(null); } }; const getModelStatus = (modelId: string): ModelCardStatus => { if (modelId in extractingModels) return "extracting"; if (modelId in downloadingModels) return "downloading"; return "downloadable"; }; const getModelDownloadProgress = (modelId: string): number | undefined => { return downloadProgress[modelId]?.percentage; }; const getModelDownloadSpeed = (modelId: string): number | undefined => { return downloadStats[modelId]?.speed; }; return (

{t("onboarding.subtitle")}

{models .filter((m: ModelInfo) => !m.is_downloaded) .filter((model: ModelInfo) => model.is_recommended) .map((model: ModelInfo) => ( ))} {models .filter((m: ModelInfo) => !m.is_downloaded) .filter((model: ModelInfo) => !model.is_recommended) .sort( (a: ModelInfo, b: ModelInfo) => Number(a.size_mb) - Number(b.size_mb), ) .map((model: ModelInfo) => ( ))}
); }; export default Onboarding; ================================================ FILE: src/components/onboarding/index.ts ================================================ export { default } from "./Onboarding"; export { default as AccessibilityOnboarding } from "./AccessibilityOnboarding"; export { default as ModelCard } from "./ModelCard"; export type { ModelCardStatus } from "./ModelCard"; ================================================ FILE: src/components/settings/AccelerationSelector.tsx ================================================ import { type FC, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { SettingContainer } from "../ui/SettingContainer"; import { Dropdown, type DropdownOption } from "../ui/Dropdown"; import { useSettings } from "../../hooks/useSettings"; import { commands } from "@/bindings"; import type { WhisperAcceleratorSetting, OrtAcceleratorSetting, } from "@/bindings"; const WHISPER_LABELS: Record = { auto: "Auto", cpu: "CPU", gpu: "GPU", }; const ORT_LABELS: Record = { auto: "Auto", cpu: "CPU", cuda: "CUDA", directml: "DirectML", rocm: "ROCm", }; interface AccelerationSelectorProps { descriptionMode?: "tooltip" | "inline"; grouped?: boolean; } export const AccelerationSelector: FC = ({ descriptionMode = "tooltip", grouped = false, }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const [whisperOptions, setWhisperOptions] = useState([]); const [ortOptions, setOrtOptions] = useState([]); useEffect(() => { commands.getAvailableAccelerators().then((available) => { setWhisperOptions( available.whisper.map((v) => ({ value: v, label: WHISPER_LABELS[v as WhisperAcceleratorSetting] ?? v, })), ); // Always include "auto" for ORT even though available() only returns compiled-in backends const ortVals = available.ort.includes("auto") ? available.ort : ["auto", ...available.ort]; setOrtOptions( ortVals.map((v) => ({ value: v, label: ORT_LABELS[v as OrtAcceleratorSetting] ?? v, })), ); }); }, []); const currentWhisper = getSetting("whisper_accelerator") ?? "auto"; const currentOrt = getSetting("ort_accelerator") ?? "auto"; return ( <> updateSetting( "whisper_accelerator", value as WhisperAcceleratorSetting, ) } disabled={isUpdating("whisper_accelerator")} /> {ortOptions.length > 2 && ( updateSetting("ort_accelerator", value as OrtAcceleratorSetting) } disabled={isUpdating("ort_accelerator")} /> )} ); }; ================================================ FILE: src/components/settings/AlwaysOnMicrophone.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface AlwaysOnMicrophoneProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const AlwaysOnMicrophone: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const alwaysOnMode = getSetting("always_on_microphone") || false; return ( updateSetting("always_on_microphone", enabled)} isUpdating={isUpdating("always_on_microphone")} label={t("settings.debug.alwaysOnMicrophone.label")} description={t("settings.debug.alwaysOnMicrophone.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }, ); ================================================ FILE: src/components/settings/AppDataDirectory.tsx ================================================ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { commands } from "@/bindings"; import { SettingContainer } from "../ui/SettingContainer"; import { PathDisplay } from "../ui/PathDisplay"; interface AppDataDirectoryProps { descriptionMode?: "tooltip" | "inline"; grouped?: boolean; } export const AppDataDirectory: React.FC = ({ descriptionMode = "inline", grouped = false, }) => { const { t } = useTranslation(); const [appDirPath, setAppDirPath] = useState(""); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const loadAppDirectory = async () => { try { const result = await commands.getAppDirPath(); if (result.status === "ok") { setAppDirPath(result.data); } else { setError(result.error); } } catch (err) { setError( err instanceof Error ? err.message : "Failed to load app directory", ); } finally { setLoading(false); } }; loadAppDirectory(); }, []); const handleOpen = async () => { if (!appDirPath) return; try { await commands.openAppDataDir(); } catch (openError) { console.error("Failed to open app data directory:", openError); } }; if (loading) { return (
); } if (error) { return (

{t("errors.loadDirectory", { error })}

); } return ( ); }; ================================================ FILE: src/components/settings/AppLanguageSelector.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from "../../i18n"; import { useSettings } from "@/hooks/useSettings"; interface AppLanguageSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const AppLanguageSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t, i18n } = useTranslation(); const { settings, updateSetting } = useSettings(); const currentLanguage = (settings?.app_language || i18n.language) as SupportedLanguageCode; const languageOptions = SUPPORTED_LANGUAGES.map((lang) => ({ value: lang.code, label: `${lang.nativeName} (${lang.name})`, })); const handleLanguageChange = (langCode: string) => { i18n.changeLanguage(langCode); updateSetting("app_language", langCode); }; return ( ); }); AppLanguageSelector.displayName = "AppLanguageSelector"; ================================================ FILE: src/components/settings/AppendTrailingSpace.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface AppendTrailingSpaceProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const AppendTrailingSpace: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("append_trailing_space") ?? false; return ( updateSetting("append_trailing_space", enabled)} isUpdating={isUpdating("append_trailing_space")} label={t("settings.debug.appendTrailingSpace.label")} description={t("settings.debug.appendTrailingSpace.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }); ================================================ FILE: src/components/settings/AudioFeedback.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; import { VolumeSlider } from "./VolumeSlider"; import { SoundPicker } from "./SoundPicker"; interface AudioFeedbackProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const AudioFeedback: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const audioFeedbackEnabled = getSetting("audio_feedback") || false; return (
updateSetting("audio_feedback", enabled)} isUpdating={isUpdating("audio_feedback")} label={t("settings.sound.audioFeedback.label")} description={t("settings.sound.audioFeedback.description")} descriptionMode={descriptionMode} grouped={grouped} />
); }, ); ================================================ FILE: src/components/settings/AutoSubmit.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import type { AutoSubmitKey } from "@/bindings"; interface AutoSubmitProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } type AutoSubmitOptionValue = AutoSubmitKey | "off"; export const AutoSubmit: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const osType = useOsType(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("auto_submit") ?? false; const selectedKey = (getSetting("auto_submit_key") || "enter") as AutoSubmitKey; const selectedValue: AutoSubmitOptionValue = enabled ? selectedKey : "off"; const submitWithMetaLabel = osType === "macos" ? t("settings.advanced.autoSubmit.options.cmdEnter") : t("settings.advanced.autoSubmit.options.superEnter"); const autoSubmitOptions = [ { value: "off", label: t("settings.advanced.autoSubmit.options.off"), }, { value: "enter", label: t("settings.advanced.autoSubmit.options.enter"), }, { value: "ctrl_enter", label: t("settings.advanced.autoSubmit.options.ctrlEnter"), }, { value: "cmd_enter", label: submitWithMetaLabel, }, ]; const handleAutoSubmitSelect = async (value: string) => { const selected = value as AutoSubmitOptionValue; if (selected === "off") { await updateSetting("auto_submit", false); return; } await updateSetting("auto_submit_key", selected as AutoSubmitKey); if (!enabled) { await updateSetting("auto_submit", true); } }; return ( ); }, ); ================================================ FILE: src/components/settings/AutostartToggle.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface AutostartToggleProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const AutostartToggle: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const autostartEnabled = getSetting("autostart_enabled") ?? false; return ( updateSetting("autostart_enabled", enabled)} isUpdating={isUpdating("autostart_enabled")} label={t("settings.advanced.autostart.label")} description={t("settings.advanced.autostart.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }, ); ================================================ FILE: src/components/settings/ClamshellMicrophoneSelector.tsx ================================================ import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { commands } from "@/bindings"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; interface ClamshellMicrophoneSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const ClamshellMicrophoneSelector: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, resetSetting, isUpdating, isLoading, audioDevices, refreshAudioDevices, } = useSettings(); const [isLaptop, setIsLaptop] = useState(false); useEffect(() => { const checkIsLaptop = async () => { try { const result = await commands.isLaptop(); if (result.status === "ok") { setIsLaptop(result.data); } else { setIsLaptop(false); } } catch (error) { console.error("Failed to check if device is laptop:", error); setIsLaptop(false); } }; checkIsLaptop(); }, []); // Only render on laptops if (!isLaptop) { return null; } const selectedClamshellMicrophone = getSetting("clamshell_microphone") === "default" ? "Default" : getSetting("clamshell_microphone") || "Default"; const handleClamshellMicrophoneSelect = async (deviceName: string) => { await updateSetting("clamshell_microphone", deviceName); }; const handleReset = async () => { await resetSetting("clamshell_microphone"); }; const microphoneOptions = audioDevices.map((device) => ({ value: device.name, label: device.name, })); return (
); }); ClamshellMicrophoneSelector.displayName = "ClamshellMicrophoneSelector"; ================================================ FILE: src/components/settings/ClipboardHandling.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import type { ClipboardHandling } from "@/bindings"; interface ClipboardHandlingProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const ClipboardHandlingSetting: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const clipboardHandlingOptions = [ { value: "dont_modify", label: t("settings.advanced.clipboardHandling.options.dontModify"), }, { value: "copy_to_clipboard", label: t("settings.advanced.clipboardHandling.options.copyToClipboard"), }, ]; const selectedHandling = (getSetting("clipboard_handling") || "dont_modify") as ClipboardHandling; return ( updateSetting("clipboard_handling", value as ClipboardHandling) } disabled={isUpdating("clipboard_handling")} /> ); }); ================================================ FILE: src/components/settings/CustomWords.tsx ================================================ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { useSettings } from "../../hooks/useSettings"; import { Input } from "../ui/Input"; import { Button } from "../ui/Button"; import { SettingContainer } from "../ui/SettingContainer"; interface CustomWordsProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const CustomWords: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const [newWord, setNewWord] = useState(""); const customWords = getSetting("custom_words") || []; const handleAddWord = () => { const trimmedWord = newWord.trim(); const sanitizedWord = trimmedWord.replace(/[<>"'&]/g, ""); if ( sanitizedWord && !sanitizedWord.includes(" ") && sanitizedWord.length <= 50 ) { if (customWords.includes(sanitizedWord)) { toast.error( t("settings.advanced.customWords.duplicate", { word: sanitizedWord, }), ); return; } updateSetting("custom_words", [...customWords, sanitizedWord]); setNewWord(""); } }; const handleRemoveWord = (wordToRemove: string) => { updateSetting( "custom_words", customWords.filter((word) => word !== wordToRemove), ); }; const handleKeyPress = (e: React.KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); handleAddWord(); } }; return ( <>
setNewWord(e.target.value)} onKeyDown={handleKeyPress} placeholder={t("settings.advanced.customWords.placeholder")} variant="compact" disabled={isUpdating("custom_words")} />
{customWords.length > 0 && (
{customWords.map((word) => ( ))}
)} ); }, ); ================================================ FILE: src/components/settings/ExperimentalToggle.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface ExperimentalToggleProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const ExperimentalToggle: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("experimental_enabled") || false; return ( updateSetting("experimental_enabled", enabled)} isUpdating={isUpdating("experimental_enabled")} label={t("settings.advanced.experimentalToggle.label")} description={t("settings.advanced.experimentalToggle.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }, ); ================================================ FILE: src/components/settings/GlobalShortcutInput.tsx ================================================ import React, { useEffect, useState, useRef } from "react"; import { useTranslation } from "react-i18next"; import { getKeyName, formatKeyCombination, normalizeKey, } from "../../lib/utils/keyboard"; import { ResetButton } from "../ui/ResetButton"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; interface GlobalShortcutInputProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; shortcutId: string; disabled?: boolean; } export const GlobalShortcutInput: React.FC = ({ descriptionMode = "tooltip", grouped = false, shortcutId, disabled = false, }) => { const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = useSettings(); const [keyPressed, setKeyPressed] = useState([]); const [recordedKeys, setRecordedKeys] = useState([]); const [editingShortcutId, setEditingShortcutId] = useState( null, ); const [originalBinding, setOriginalBinding] = useState(""); const shortcutRefs = useRef>(new Map()); const osType = useOsType(); const bindings = getSetting("bindings") || {}; useEffect(() => { // Only add event listeners when we're in editing mode if (editingShortcutId === null) return; let cleanup = false; // Keyboard event listeners const handleKeyDown = async (e: KeyboardEvent) => { if (cleanup) return; if (e.repeat) return; // ignore auto-repeat if (e.key === "Escape") { // Cancel recording and restore original binding if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (error) { console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { await commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); return; } e.preventDefault(); // Get the key with OS-specific naming and normalize it const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); if (!keyPressed.includes(key)) { setKeyPressed((prev) => [...prev, key]); // Also add to recorded keys if not already there if (!recordedKeys.includes(key)) { setRecordedKeys((prev) => [...prev, key]); } } }; const handleKeyUp = async (e: KeyboardEvent) => { if (cleanup) return; e.preventDefault(); // Get the key with OS-specific naming and normalize it const rawKey = getKeyName(e, osType); const key = normalizeKey(rawKey); // Remove from currently pressed keys setKeyPressed((prev) => prev.filter((k) => k !== key)); // If no keys are pressed anymore, commit the shortcut const updatedKeyPressed = keyPressed.filter((k) => k !== key); if (updatedKeyPressed.length === 0 && recordedKeys.length > 0) { // Create the shortcut string from all recorded keys // Sort keys so modifiers come first, then the main key const modifiers = [ "ctrl", "control", "shift", "alt", "option", "meta", "command", "cmd", "super", "win", "windows", ]; const sortedKeys = recordedKeys.sort((a, b) => { const aIsModifier = modifiers.includes(a.toLowerCase()); const bIsModifier = modifiers.includes(b.toLowerCase()); if (aIsModifier && !bIsModifier) return -1; if (!aIsModifier && bIsModifier) return 1; return 0; }); const newShortcut = sortedKeys.join("+"); if (editingShortcutId && bindings[editingShortcutId]) { try { await updateBinding(editingShortcutId, newShortcut); } catch (error) { console.error("Failed to change binding:", error); toast.error( t("settings.general.shortcut.errors.set", { error: String(error), }), ); // Reset to original binding on error if (originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (resetError) { console.error("Failed to reset binding:", resetError); toast.error(t("settings.general.shortcut.errors.reset")); } } } // Exit editing mode and reset states setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); } } }; // Add click outside handler const handleClickOutside = async (e: MouseEvent) => { if (cleanup) return; const activeElement = shortcutRefs.current.get(editingShortcutId); if (activeElement && !activeElement.contains(e.target as Node)) { // Cancel shortcut recording and restore original binding if (editingShortcutId && originalBinding) { try { await updateBinding(editingShortcutId, originalBinding); } catch (error) { console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } } else if (editingShortcutId) { commands.resumeBinding(editingShortcutId).catch(console.error); } setEditingShortcutId(null); setKeyPressed([]); setRecordedKeys([]); setOriginalBinding(""); } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); window.addEventListener("click", handleClickOutside); return () => { cleanup = true; window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("click", handleClickOutside); }; }, [ keyPressed, recordedKeys, editingShortcutId, bindings, originalBinding, updateBinding, osType, ]); // Start recording a new shortcut const startRecording = async (id: string) => { if (editingShortcutId === id) return; // Already editing this shortcut // Suspend current binding to avoid firing while recording await commands.suspendBinding(id).catch(console.error); // Store the original binding to restore if canceled setOriginalBinding(bindings[id]?.current_binding || ""); setEditingShortcutId(id); setKeyPressed([]); setRecordedKeys([]); }; // Format the current shortcut keys being recorded const formatCurrentKeys = (): string => { if (recordedKeys.length === 0) return t("settings.general.shortcut.pressKeys"); // Use the same formatting as the display to ensure consistency return formatKeyCombination(recordedKeys.join("+"), osType); }; // Store references to shortcut elements const setShortcutRef = (id: string, ref: HTMLDivElement | null) => { shortcutRefs.current.set(id, ref); }; // If still loading, show loading state if (isLoading) { return (
{t("settings.general.shortcut.loading")}
); } // If no bindings are loaded, show empty state if (Object.keys(bindings).length === 0) { return (
{t("settings.general.shortcut.none")}
); } const binding = bindings[shortcutId]; if (!binding) { return (
{t("settings.general.shortcut.none")}
); } // Get translated name and description for the binding const translatedName = t( `settings.general.shortcut.bindings.${shortcutId}.name`, binding.name, ); const translatedDescription = t( `settings.general.shortcut.bindings.${shortcutId}.description`, binding.description, ); return (
{editingShortcutId === shortcutId ? (
setShortcutRef(shortcutId, ref)} className="px-2 py-1 text-sm font-semibold border border-logo-primary bg-logo-primary/30 rounded-md" > {formatCurrentKeys()}
) : (
startRecording(shortcutId)} > {formatKeyCombination(binding.current_binding, osType)}
)} resetBinding(shortcutId)} disabled={isUpdating(`binding_${shortcutId}`)} />
); }; ================================================ FILE: src/components/settings/HandyKeysShortcutInput.tsx ================================================ import React, { useEffect, useState, useRef, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { listen } from "@tauri-apps/api/event"; import { formatKeyCombination } from "../../lib/utils/keyboard"; import { ResetButton } from "../ui/ResetButton"; import { SettingContainer } from "../ui/SettingContainer"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import { commands } from "@/bindings"; import { toast } from "sonner"; interface HandyKeysShortcutInputProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; shortcutId: string; disabled?: boolean; } interface HandyKeysEvent { modifiers: string[]; key: string | null; is_key_down: boolean; hotkey_string: string; } export const HandyKeysShortcutInput: React.FC = ({ descriptionMode = "tooltip", grouped = false, shortcutId, disabled = false, }) => { const { t } = useTranslation(); const { getSetting, updateBinding, resetBinding, isUpdating, isLoading } = useSettings(); const [isRecording, setIsRecording] = useState(false); const [currentKeys, setCurrentKeys] = useState(""); const [originalBinding, setOriginalBinding] = useState(""); const shortcutRef = useRef(null); const unlistenRef = useRef<(() => void) | null>(null); // Use a ref to track currentKeys for the event handler (avoids stale closure) const currentKeysRef = useRef(""); const osType = useOsType(); const bindings = getSetting("bindings") || {}; // Handle cancellation const cancelRecording = useCallback(async () => { if (!isRecording) return; // Stop listening for backend events if (unlistenRef.current) { unlistenRef.current(); unlistenRef.current = null; } // Stop backend recording await commands.stopHandyKeysRecording().catch(console.error); // Restore original binding if (originalBinding) { try { await updateBinding(shortcutId, originalBinding); } catch (error) { console.error("Failed to restore original binding:", error); toast.error(t("settings.general.shortcut.errors.restore")); } } setIsRecording(false); setCurrentKeys(""); currentKeysRef.current = ""; setOriginalBinding(""); }, [isRecording, originalBinding, shortcutId, updateBinding, t]); // Set up event listener for handy-keys events useEffect(() => { if (!isRecording) return; let cleanup = false; const setupListener = async () => { // Listen for key events from backend const unlisten = await listen( "handy-keys-event", async (event) => { if (cleanup) return; const { hotkey_string, is_key_down } = event.payload; if (is_key_down && hotkey_string) { // Update both state (for display) and ref (for release handler) currentKeysRef.current = hotkey_string; setCurrentKeys(hotkey_string); } else if (!is_key_down && currentKeysRef.current) { // Key released - commit the shortcut using the ref value const keysToCommit = currentKeysRef.current; try { await updateBinding(shortcutId, keysToCommit); } catch (error) { console.error("Failed to change binding:", error); toast.error( t("settings.general.shortcut.errors.set", { error: String(error), }), ); // Reset to original binding on error if (originalBinding) { try { await updateBinding(shortcutId, originalBinding); } catch (resetError) { console.error("Failed to reset binding:", resetError); toast.error(t("settings.general.shortcut.errors.reset")); } } } // Stop recording if (unlistenRef.current) { unlistenRef.current(); unlistenRef.current = null; } await commands.stopHandyKeysRecording().catch(console.error); setIsRecording(false); setCurrentKeys(""); currentKeysRef.current = ""; setOriginalBinding(""); } }, ); unlistenRef.current = unlisten; }; setupListener(); // Handle escape key to cancel const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); cancelRecording(); } }; window.addEventListener("keydown", handleKeyDown); return () => { cleanup = true; window.removeEventListener("keydown", handleKeyDown); if (unlistenRef.current) { unlistenRef.current(); unlistenRef.current = null; } // Stop backend recording on unmount to prevent orphaned recording loops commands.stopHandyKeysRecording().catch(console.error); }; }, [ isRecording, shortcutId, originalBinding, updateBinding, cancelRecording, t, ]); // Handle click outside useEffect(() => { if (!isRecording) return; const handleClickOutside = (e: MouseEvent) => { if ( shortcutRef.current && !shortcutRef.current.contains(e.target as Node) ) { cancelRecording(); } }; window.addEventListener("click", handleClickOutside); return () => window.removeEventListener("click", handleClickOutside); }, [isRecording, cancelRecording]); // Start recording a new shortcut const startRecording = async () => { if (isRecording) return; // Store the original binding to restore if canceled setOriginalBinding(bindings[shortcutId]?.current_binding || ""); // Start backend recording try { await commands.startHandyKeysRecording(shortcutId); setIsRecording(true); setCurrentKeys(""); currentKeysRef.current = ""; } catch (error) { console.error("Failed to start recording:", error); toast.error( t("settings.general.shortcut.errors.set", { error: String(error) }), ); } }; // Format the current shortcut keys being recorded const formatCurrentKeys = (): string => { if (!currentKeys) return t("settings.general.shortcut.pressKeys"); return formatKeyCombination(currentKeys, osType); }; // If still loading, show loading state if (isLoading) { return (
{t("settings.general.shortcut.loading")}
); } // If no bindings are loaded, show empty state if (Object.keys(bindings).length === 0) { return (
{t("settings.general.shortcut.none")}
); } const binding = bindings[shortcutId]; if (!binding) { return (
{t("settings.general.shortcut.none")}
); } // Get translated name and description for the binding const translatedName = t( `settings.general.shortcut.bindings.${shortcutId}.name`, binding.name, ); const translatedDescription = t( `settings.general.shortcut.bindings.${shortcutId}.description`, binding.description, ); return (
{isRecording ? (
{formatCurrentKeys()}
) : (
{formatKeyCombination(binding.current_binding, osType)}
)} resetBinding(shortcutId)} disabled={isUpdating(`binding_${shortcutId}`)} />
); }; ================================================ FILE: src/components/settings/HistoryLimit.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { useSettings } from "../../hooks/useSettings"; import { Input } from "../ui/Input"; import { SettingContainer } from "../ui/SettingContainer"; interface HistoryLimitProps { descriptionMode?: "tooltip" | "inline"; grouped?: boolean; } export const HistoryLimit: React.FC = ({ descriptionMode = "inline", grouped = false, }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const historyLimit = getSetting("history_limit") ?? 5; const handleChange = async (event: React.ChangeEvent) => { const value = parseInt(event.target.value, 10); if (!isNaN(value) && value >= 0) { updateSetting("history_limit", value); } }; return (
{t("settings.debug.historyLimit.entries")}
); }; ================================================ FILE: src/components/settings/LanguageSelector.tsx ================================================ import React, { useState, useRef, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; import { LANGUAGES } from "../../lib/constants/languages"; interface LanguageSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; supportedLanguages?: string[]; } export const LanguageSelector: React.FC = ({ descriptionMode = "tooltip", grouped = false, supportedLanguages, }) => { const { t } = useTranslation(); const { getSetting, updateSetting, resetSetting, isUpdating } = useSettings(); const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); const dropdownRef = useRef(null); const searchInputRef = useRef(null); const selectedLanguage = getSetting("selected_language") || "auto"; useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) ) { setIsOpen(false); setSearchQuery(""); } }; document.addEventListener("mousedown", handleClickOutside); return () => { document.removeEventListener("mousedown", handleClickOutside); }; }, []); useEffect(() => { if (isOpen && searchInputRef.current) { searchInputRef.current.focus(); } }, [isOpen]); const availableLanguages = useMemo(() => { if (!supportedLanguages || supportedLanguages.length === 0) return LANGUAGES; return LANGUAGES.filter( (lang) => lang.value === "auto" || supportedLanguages.includes(lang.value), ); }, [supportedLanguages]); const filteredLanguages = useMemo( () => availableLanguages.filter((language) => language.label.toLowerCase().includes(searchQuery.toLowerCase()), ), [searchQuery, availableLanguages], ); const selectedLanguageName = LANGUAGES.find((lang) => lang.value === selectedLanguage)?.label || t("settings.general.language.auto"); const handleLanguageSelect = async (languageCode: string) => { await updateSetting("selected_language", languageCode); setIsOpen(false); setSearchQuery(""); }; const handleReset = async () => { await resetSetting("selected_language"); }; const handleToggle = () => { if (isUpdating("selected_language")) return; setIsOpen(!isOpen); }; const handleSearchChange = (event: React.ChangeEvent) => { setSearchQuery(event.target.value); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter" && filteredLanguages.length > 0) { // Select first filtered language on Enter handleLanguageSelect(filteredLanguages[0].value); } else if (event.key === "Escape") { setIsOpen(false); setSearchQuery(""); } }; return (
{isOpen && !isUpdating("selected_language") && (
{/* Search input */}
{filteredLanguages.length === 0 ? (
{t("settings.general.language.noResults")}
) : ( filteredLanguages.map((language) => ( )) )}
)}
{isUpdating("selected_language") && (
)}
); }; ================================================ FILE: src/components/settings/LazyStreamClose.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface LazyStreamCloseProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const LazyStreamClose: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const enabled = getSetting("lazy_stream_close") ?? false; return ( updateSetting("lazy_stream_close", enabled)} isUpdating={isUpdating("lazy_stream_close")} label={t("settings.advanced.lazyStreamClose.label")} description={t("settings.advanced.lazyStreamClose.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }, ); ================================================ FILE: src/components/settings/MicrophoneSelector.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; interface MicrophoneSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const MicrophoneSelector: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, resetSetting, isUpdating, isLoading, audioDevices, refreshAudioDevices, } = useSettings(); const selectedMicrophone = getSetting("selected_microphone") === "default" ? "Default" : getSetting("selected_microphone") || "Default"; const handleMicrophoneSelect = async (deviceName: string) => { await updateSetting("selected_microphone", deviceName); }; const handleReset = async () => { await resetSetting("selected_microphone"); }; const microphoneOptions = audioDevices.map((device) => ({ value: device.name, label: device.name, })); return (
); }, ); ================================================ FILE: src/components/settings/ModelUnloadTimeout.tsx ================================================ import React, { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useSettings } from "../../hooks/useSettings"; import { commands, type ModelUnloadTimeout } from "@/bindings"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; interface ModelUnloadTimeoutProps { descriptionMode?: "tooltip" | "inline"; grouped?: boolean; } export const ModelUnloadTimeoutSetting: React.FC = ({ descriptionMode = "inline", grouped = false, }) => { const { t } = useTranslation(); const { settings, getSetting, updateSetting } = useSettings(); const timeoutOptions = [ { value: "never" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.never"), }, { value: "immediately" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.immediately"), }, { value: "min2" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min2"), }, { value: "min5" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min5"), }, { value: "min10" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min10"), }, { value: "min15" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.min15"), }, { value: "hour1" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.hour1"), }, ]; const debugTimeoutOptions = [ ...timeoutOptions, { value: "sec15" as ModelUnloadTimeout, label: t("settings.advanced.modelUnload.options.sec15"), }, ]; const handleChange = async (event: React.ChangeEvent) => { const newTimeout = event.target.value as ModelUnloadTimeout; try { await commands.setModelUnloadTimeout(newTimeout); updateSetting("model_unload_timeout", newTimeout); } catch (error) { console.error("Failed to update model unload timeout:", error); } }; const currentValue = getSetting("model_unload_timeout") ?? "never"; const options = useMemo(() => { return settings?.debug_mode === true ? debugTimeoutOptions : timeoutOptions; }, [settings]); return ( handleChange({ target: { value }, } as React.ChangeEvent) } disabled={false} /> ); }; ================================================ FILE: src/components/settings/MuteWhileRecording.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { useSettings } from "../../hooks/useSettings"; interface MuteWhileRecordingToggleProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const MuteWhileRecording: React.FC = React.memo(({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const muteEnabled = getSetting("mute_while_recording") ?? false; return ( updateSetting("mute_while_recording", enabled)} isUpdating={isUpdating("mute_while_recording")} label={t("settings.debug.muteWhileRecording.label")} description={t("settings.debug.muteWhileRecording.description")} descriptionMode={descriptionMode} grouped={grouped} /> ); }); ================================================ FILE: src/components/settings/OutputDeviceSelector.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { ResetButton } from "../ui/ResetButton"; import { useSettings } from "../../hooks/useSettings"; import type { AudioDevice } from "@/bindings"; interface OutputDeviceSelectorProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; disabled?: boolean; } export const OutputDeviceSelector: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false, disabled = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, resetSetting, isUpdating, isLoading, outputDevices, refreshOutputDevices, } = useSettings(); const selectedOutputDevice = getSetting("selected_output_device") === "default" ? "Default" : getSetting("selected_output_device") || "Default"; const handleOutputDeviceSelect = async (deviceName: string) => { await updateSetting("selected_output_device", deviceName); }; const handleReset = async () => { await resetSetting("selected_output_device"); }; const outputDeviceOptions = outputDevices.map((device: AudioDevice) => ({ value: device.name, label: device.name, })); return (
); }, ); ================================================ FILE: src/components/settings/PasteMethod.tsx ================================================ import React from "react"; import { useTranslation } from "react-i18next"; import { Dropdown } from "../ui/Dropdown"; import { SettingContainer } from "../ui/SettingContainer"; import { Input } from "../ui/Input"; import { useSettings } from "../../hooks/useSettings"; import { useOsType } from "../../hooks/useOsType"; import type { PasteMethod } from "@/bindings"; interface PasteMethodProps { descriptionMode?: "inline" | "tooltip"; grouped?: boolean; } export const PasteMethodSetting: React.FC = React.memo( ({ descriptionMode = "tooltip", grouped = false }) => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating } = useSettings(); const osType = useOsType(); const getPasteMethodOptions = (osType: string) => { const mod = osType === "macos" ? "Cmd" : "Ctrl"; const options = [ { value: "ctrl_v", label: t("settings.advanced.pasteMethod.options.clipboard", { modifier: mod, }), }, { value: "direct", label: t("settings.advanced.pasteMethod.options.direct"), }, { value: "none", label: t("settings.advanced.pasteMethod.options.none"), }, ]; // Add Shift+Insert and Ctrl+Shift+V options for Windows and Linux only if (osType === "windows" || osType === "linux") { options.push( { value: "ctrl_shift_v", label: t( "settings.advanced.pasteMethod.options.clipboardCtrlShiftV", ), }, { value: "shift_insert", label: t( "settings.advanced.pasteMethod.options.clipboardShiftInsert", ), }, ); } // External script is only available on Linux if (osType === "linux") { options.push({ value: "external_script", label: t("settings.advanced.pasteMethod.options.externalScript"), }); } return options; }; const selectedMethod = (getSetting("paste_method") || "ctrl_v") as PasteMethod; const externalScriptPath = getSetting("external_script_path") || ""; const pasteMethodOptions = getPasteMethodOptions(osType); return (
updateSetting("paste_method", value as PasteMethod) } disabled={isUpdating("paste_method")} /> {selectedMethod === "external_script" && ( updateSetting("external_script_path", e.target.value) } placeholder={t( "settings.advanced.pasteMethod.externalScriptPlaceholder", )} disabled={isUpdating("external_script_path")} /> )}
); }, ); ================================================ FILE: src/components/settings/PostProcessingSettingsApi/ApiKeyField.tsx ================================================ import React, { useState } from "react"; import { Input } from "../../ui/Input"; interface ApiKeyFieldProps { value: string; onBlur: (value: string) => void; disabled: boolean; placeholder?: string; className?: string; } export const ApiKeyField: React.FC = React.memo( ({ value, onBlur, disabled, placeholder, className = "" }) => { const [localValue, setLocalValue] = useState(value); // Sync with prop changes React.useEffect(() => { setLocalValue(value); }, [value]); return ( setLocalValue(event.target.value)} onBlur={() => onBlur(localValue)} placeholder={placeholder} variant="compact" disabled={disabled} className={`flex-1 min-w-[320px] ${className}`} /> ); }, ); ApiKeyField.displayName = "ApiKeyField"; ================================================ FILE: src/components/settings/PostProcessingSettingsApi/BaseUrlField.tsx ================================================ import React, { useState } from "react"; import { Input } from "../../ui/Input"; interface BaseUrlFieldProps { value: string; onBlur: (value: string) => void; disabled: boolean; placeholder?: string; className?: string; } export const BaseUrlField: React.FC = React.memo( ({ value, onBlur, disabled, placeholder, className = "" }) => { const [localValue, setLocalValue] = useState(value); // Sync with prop changes React.useEffect(() => { setLocalValue(value); }, [value]); const disabledMessage = disabled ? "Base URL is managed by the selected provider." : undefined; return ( setLocalValue(event.target.value)} onBlur={() => onBlur(localValue)} placeholder={placeholder} variant="compact" disabled={disabled} className={`flex-1 min-w-[360px] ${className}`} title={disabledMessage} /> ); }, ); BaseUrlField.displayName = "BaseUrlField"; ================================================ FILE: src/components/settings/PostProcessingSettingsApi/ModelSelect.tsx ================================================ import React from "react"; import type { ModelOption } from "./types"; import { Select } from "../../ui/Select"; type ModelSelectProps = { value: string; options: ModelOption[]; disabled?: boolean; placeholder?: string; isLoading?: boolean; onSelect: (value: string) => void; onCreate: (value: string) => void; onBlur: () => void; className?: string; }; export const ModelSelect: React.FC = React.memo( ({ value, options, disabled, placeholder, isLoading, onSelect, onCreate, onBlur, className = "flex-1 min-w-[360px]", }) => { const handleCreate = (inputValue: string) => { const trimmed = inputValue.trim(); if (!trimmed) return; onCreate(trimmed); }; const computedClassName = `text-sm ${className}`; return ( setLanguageSearch(e.target.value)} onKeyDown={(e) => { if ( e.key === "Enter" && filteredLanguages.length > 0 ) { setLanguageFilter(filteredLanguages[0].value); setLanguageDropdownOpen(false); setLanguageSearch(""); } else if (e.key === "Escape") { setLanguageDropdownOpen(false); setLanguageSearch(""); } }} placeholder={t( "settings.general.language.searchPlaceholder", )} 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" />
{filteredLanguages.map((lang) => ( ))} {filteredLanguages.length === 0 && (
{t("settings.general.language.noResults")}
)}
)}
{downloadedModels.map((model: ModelInfo) => ( ))} {/* Available Models Section */} {availableModels.length > 0 && (

{t("settings.models.availableModels")}

{availableModels.map((model: ModelInfo) => ( ))}
)} ) : (
{t("settings.models.noModelsMatch")}
)} ); }; ================================================ FILE: src/components/settings/models/index.ts ================================================ export { ModelsSettings } from "./ModelsSettings"; ================================================ FILE: src/components/settings/post-processing/PostProcessingSettings.tsx ================================================ import React, { useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { RefreshCcw } from "lucide-react"; import { commands } from "@/bindings"; import { Alert } from "../../ui/Alert"; import { Dropdown, SettingContainer, SettingsGroup, Textarea, } from "@/components/ui"; import { Button } from "../../ui/Button"; import { ResetButton } from "../../ui/ResetButton"; import { Input } from "../../ui/Input"; import { ProviderSelect } from "../PostProcessingSettingsApi/ProviderSelect"; import { BaseUrlField } from "../PostProcessingSettingsApi/BaseUrlField"; import { ApiKeyField } from "../PostProcessingSettingsApi/ApiKeyField"; import { ModelSelect } from "../PostProcessingSettingsApi/ModelSelect"; import { usePostProcessProviderState } from "../PostProcessingSettingsApi/usePostProcessProviderState"; import { ShortcutInput } from "../ShortcutInput"; import { useSettings } from "../../../hooks/useSettings"; const PostProcessingSettingsApiComponent: React.FC = () => { const { t } = useTranslation(); const state = usePostProcessProviderState(); return ( <>
{state.isAppleProvider ? ( state.appleIntelligenceUnavailable ? ( {t("settings.postProcessing.api.appleIntelligence.unavailable")} ) : null ) : ( <> {state.selectedProvider?.id === "custom" && (
)}
)} {!state.isAppleProvider && (
0 ? t( "settings.postProcessing.api.model.placeholderWithOptions", ) : t("settings.postProcessing.api.model.placeholderNoOptions") } onSelect={state.handleModelSelect} onCreate={state.handleModelCreate} onBlur={() => {}} className="flex-1 min-w-[380px]" />
)} ); }; const PostProcessingSettingsPromptsComponent: React.FC = () => { const { t } = useTranslation(); const { getSetting, updateSetting, isUpdating, refreshSettings } = useSettings(); const [isCreating, setIsCreating] = useState(false); const [draftName, setDraftName] = useState(""); const [draftText, setDraftText] = useState(""); const prompts = getSetting("post_process_prompts") || []; const selectedPromptId = getSetting("post_process_selected_prompt_id") || ""; const selectedPrompt = prompts.find((prompt) => prompt.id === selectedPromptId) || null; useEffect(() => { if (isCreating) return; if (selectedPrompt) { setDraftName(selectedPrompt.name); setDraftText(selectedPrompt.prompt); } else { setDraftName(""); setDraftText(""); } }, [ isCreating, selectedPromptId, selectedPrompt?.name, selectedPrompt?.prompt, ]); const handlePromptSelect = (promptId: string | null) => { if (!promptId) return; updateSetting("post_process_selected_prompt_id", promptId); setIsCreating(false); }; const handleCreatePrompt = async () => { if (!draftName.trim() || !draftText.trim()) return; try { const result = await commands.addPostProcessPrompt( draftName.trim(), draftText.trim(), ); if (result.status === "ok") { await refreshSettings(); updateSetting("post_process_selected_prompt_id", result.data.id); setIsCreating(false); } } catch (error) { console.error("Failed to create prompt:", error); } }; const handleUpdatePrompt = async () => { if (!selectedPromptId || !draftName.trim() || !draftText.trim()) return; try { await commands.updatePostProcessPrompt( selectedPromptId, draftName.trim(), draftText.trim(), ); await refreshSettings(); } catch (error) { console.error("Failed to update prompt:", error); } }; const handleDeletePrompt = async (promptId: string) => { if (!promptId) return; try { await commands.deletePostProcessPrompt(promptId); await refreshSettings(); setIsCreating(false); } catch (error) { console.error("Failed to delete prompt:", error); } }; const handleCancelCreate = () => { setIsCreating(false); if (selectedPrompt) { setDraftName(selectedPrompt.name); setDraftText(selectedPrompt.prompt); } else { setDraftName(""); setDraftText(""); } }; const handleStartCreate = () => { setIsCreating(true); setDraftName(""); setDraftText(""); }; const hasPrompts = prompts.length > 0; const isDirty = !!selectedPrompt && (draftName.trim() !== selectedPrompt.name || draftText.trim() !== selectedPrompt.prompt.trim()); return (
({ value: p.id, label: p.name, }))} onSelect={(value) => handlePromptSelect(value)} placeholder={ prompts.length === 0 ? t("settings.postProcessing.prompts.noPrompts") : t("settings.postProcessing.prompts.selectPrompt") } disabled={ isUpdating("post_process_selected_prompt_id") || isCreating } className="flex-1" />
{!isCreating && hasPrompts && selectedPrompt && (
setDraftName(e.target.value)} placeholder={t( "settings.postProcessing.prompts.promptLabelPlaceholder", )} variant="compact" />