From c0f1091ff6d154d0d62d7d6bc5a171a2c5fb33cc Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Tue, 27 Jan 2026 16:30:55 -0500 Subject: [PATCH 01/33] Add macOS support Platform support: - CGEventTap-based global hotkey detection (FN/Globe key) - CGEvent text injection with osascript fallback - pbcopy clipboard integration - Native menu bar and system notifications - Hammerspoon integration for advanced users Build and distribution: - Universal binary build script (x86_64 + arm64) - Code signing and notarization scripts - DMG packaging with drag-to-Applications - Homebrew formula - LaunchAgent for auto-start Fixes: - CGEvent modifier flags: prevent Caps Lock causing random capitalization - Metal backend crash: align audio_ctx to multiple of 8 - Whisper hallucination: set no_context=true to prevent phrase repetition Co-authored-by: Christopher Albert --- .github/workflows/build-linux.yml | 291 +++++++ .github/workflows/build-macos.yml | 172 ++++ Cargo.lock | 1317 +++++++++++++++++++++++++++-- Cargo.toml | 24 +- assets/icon.png | Bin 0 -> 3554 bytes config/default.toml | 6 - contrib/hammerspoon/README.md | 73 ++ contrib/hammerspoon/voxtype.lua | 188 ++++ docs/CONFIGURATION.md | 49 +- docs/INSTALL.md | 7 +- docs/INSTALL_MACOS.md | 213 +++++ docs/TROUBLESHOOTING.md | 217 +---- docs/USER_MANUAL.md | 64 -- flake.nix | 8 +- packaging/debian/voxtype.service | 7 - packaging/homebrew/voxtype.rb | 63 ++ packaging/systemd/voxtype.service | 7 - scripts/build-macos-dmg.sh | 116 +++ scripts/build-macos.sh | 89 ++ scripts/notarize-macos.sh | 94 ++ scripts/sign-macos.sh | 78 ++ src/cli.rs | 45 +- src/config.rs | 6 +- src/cpu.rs | 5 +- src/daemon.rs | 170 +++- src/error.rs | 3 + src/hotkey/evdev_listener.rs | 5 +- src/hotkey/mod.rs | 7 +- src/hotkey_macos.rs | 249 ++++++ src/lib.rs | 6 + src/main.rs | 116 ++- src/menubar.rs | 196 +++++ src/notification.rs | 224 +++++ src/output/cgevent.rs | 271 ++++++ src/output/mod.rs | 112 ++- src/output/osascript.rs | 184 ++++ src/output/pbcopy.rs | 133 +++ src/output/post_process.rs | 8 +- src/setup/hammerspoon.rs | 127 +++ src/setup/launchd.rs | 245 ++++++ src/setup/macos.rs | 352 ++++++++ src/setup/mod.rs | 167 ++-- src/setup/model.rs | 1 + src/transcribe/subprocess.rs | 2 +- src/transcribe/whisper.rs | 4 + website/appcast.xml | 36 + website/index.html | 2 +- 47 files changed, 5186 insertions(+), 573 deletions(-) create mode 100644 .github/workflows/build-linux.yml create mode 100644 .github/workflows/build-macos.yml create mode 100644 assets/icon.png create mode 100644 contrib/hammerspoon/README.md create mode 100644 contrib/hammerspoon/voxtype.lua create mode 100644 docs/INSTALL_MACOS.md create mode 100644 packaging/homebrew/voxtype.rb create mode 100755 scripts/build-macos-dmg.sh create mode 100755 scripts/build-macos.sh create mode 100755 scripts/notarize-macos.sh create mode 100755 scripts/sign-macos.sh create mode 100644 src/hotkey_macos.rs create mode 100644 src/menubar.rs create mode 100644 src/notification.rs create mode 100644 src/output/cgevent.rs create mode 100644 src/output/osascript.rs create mode 100644 src/output/pbcopy.rs create mode 100644 src/setup/hammerspoon.rs create mode 100644 src/setup/launchd.rs create mode 100644 src/setup/macos.rs create mode 100644 website/appcast.xml diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 00000000..cb26e2ca --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,291 @@ +name: Build Linux + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version (without v prefix)' + required: true + default: '0.5.0' + +jobs: + # AVX2 build - uses Docker for clean toolchain (no AVX-512 contamination) + build-avx2: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Build AVX2 binary in Docker + run: | + VERSION=${{ steps.version.outputs.version }} + docker build -f Dockerfile.build -t voxtype-avx2 --build-arg VERSION=${VERSION} . + mkdir -p releases/${VERSION} + docker run --rm -v $(pwd)/releases/${VERSION}:/output voxtype-avx2 + + - name: Verify binary + run: | + VERSION=${{ steps.version.outputs.version }} + chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx2 + releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx2 --version + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: linux-avx2 + path: releases/${{ steps.version.outputs.version }}/voxtype-*-linux-x86_64-avx2 + + # Vulkan build - uses Docker for clean toolchain + build-vulkan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Build Vulkan binary in Docker + run: | + VERSION=${{ steps.version.outputs.version }} + docker build -f Dockerfile.vulkan -t voxtype-vulkan --build-arg VERSION=${VERSION} . + mkdir -p releases/${VERSION} + docker run --rm -v $(pwd)/releases/${VERSION}:/output voxtype-vulkan + + - name: Verify binary + run: | + VERSION=${{ steps.version.outputs.version }} + chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-vulkan + releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-vulkan --version + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: linux-vulkan + path: releases/${{ steps.version.outputs.version }}/voxtype-*-linux-x86_64-vulkan + + # Parakeet AVX2 build - uses Docker for clean toolchain + build-parakeet-avx2: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Build Parakeet AVX2 binary in Docker + run: | + VERSION=${{ steps.version.outputs.version }} + docker build -f Dockerfile.parakeet -t voxtype-parakeet-avx2 --build-arg VERSION=${VERSION} . + mkdir -p releases/${VERSION} + docker run --rm -v $(pwd)/releases/${VERSION}:/output voxtype-parakeet-avx2 + + - name: Verify binary + run: | + VERSION=${{ steps.version.outputs.version }} + chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 + releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 --version + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: linux-parakeet-avx2 + path: releases/${{ steps.version.outputs.version }}/voxtype-*-linux-x86_64-parakeet-avx2 + + # AVX-512 build - requires AVX-512 capable runner (best effort) + build-avx512: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for AVX-512 support + id: check-avx512 + run: | + if grep -q avx512f /proc/cpuinfo; then + echo "supported=true" >> $GITHUB_OUTPUT + echo "AVX-512 is supported on this runner" + else + echo "supported=false" >> $GITHUB_OUTPUT + echo "AVX-512 is NOT supported on this runner - skipping build" + fi + + - name: Determine version + if: steps.check-avx512.outputs.supported == 'true' + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Install Rust + if: steps.check-avx512.outputs.supported == 'true' + uses: dtolnay/rust-action@stable + + - name: Install dependencies + if: steps.check-avx512.outputs.supported == 'true' + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev clang cmake + + - name: Build AVX-512 binary + if: steps.check-avx512.outputs.supported == 'true' + env: + RUSTFLAGS: "-C target-cpu=native" + run: | + cargo build --release + VERSION=${{ steps.version.outputs.version }} + mkdir -p releases/${VERSION} + cp target/release/voxtype releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx512 + + - name: Verify binary + if: steps.check-avx512.outputs.supported == 'true' + run: | + VERSION=${{ steps.version.outputs.version }} + releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx512 --version + + - name: Upload artifact + if: steps.check-avx512.outputs.supported == 'true' + uses: actions/upload-artifact@v4 + with: + name: linux-avx512 + path: releases/${{ steps.version.outputs.version }}/voxtype-*-linux-x86_64-avx512 + + # Parakeet AVX-512 build - requires AVX-512 capable runner (best effort) + build-parakeet-avx512: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check for AVX-512 support + id: check-avx512 + run: | + if grep -q avx512f /proc/cpuinfo; then + echo "supported=true" >> $GITHUB_OUTPUT + echo "AVX-512 is supported on this runner" + else + echo "supported=false" >> $GITHUB_OUTPUT + echo "AVX-512 is NOT supported on this runner - skipping build" + fi + + - name: Determine version + if: steps.check-avx512.outputs.supported == 'true' + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Install Rust + if: steps.check-avx512.outputs.supported == 'true' + uses: dtolnay/rust-action@stable + + - name: Install dependencies + if: steps.check-avx512.outputs.supported == 'true' + run: | + sudo apt-get update + sudo apt-get install -y libasound2-dev clang cmake + + - name: Build Parakeet AVX-512 binary + if: steps.check-avx512.outputs.supported == 'true' + env: + RUSTFLAGS: "-C target-cpu=native" + run: | + cargo build --release --features parakeet + VERSION=${{ steps.version.outputs.version }} + mkdir -p releases/${VERSION} + cp target/release/voxtype releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx512 + + - name: Verify binary + if: steps.check-avx512.outputs.supported == 'true' + run: | + VERSION=${{ steps.version.outputs.version }} + releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx512 --version + + - name: Upload artifact + if: steps.check-avx512.outputs.supported == 'true' + uses: actions/upload-artifact@v4 + with: + name: linux-parakeet-avx512 + path: releases/${{ steps.version.outputs.version }}/voxtype-*-linux-x86_64-parakeet-avx512 + + # Collect all artifacts and create release + release: + needs: [build-avx2, build-vulkan, build-parakeet-avx2, build-avx512, build-parakeet-avx512] + if: always() && needs.build-avx2.result == 'success' + runs-on: ubuntu-latest + steps: + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Collect binaries + run: | + VERSION=${{ steps.version.outputs.version }} + mkdir -p releases/${VERSION} + + # Move all binaries to releases directory + find artifacts -name 'voxtype-*' -type f -exec mv {} releases/${VERSION}/ \; + + # List collected binaries + echo "Collected binaries:" + ls -la releases/${VERSION}/ + + - name: Generate checksums + run: | + VERSION=${{ steps.version.outputs.version }} + cd releases/${VERSION} + sha256sum voxtype-* > SHA256SUMS.txt + echo "Checksums:" + cat SHA256SUMS.txt + + - name: Upload combined artifact + uses: actions/upload-artifact@v4 + with: + name: linux-release-all + path: | + releases/${{ steps.version.outputs.version }}/voxtype-* + releases/${{ steps.version.outputs.version }}/SHA256SUMS.txt + + - name: Upload to release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: | + releases/${{ steps.version.outputs.version }}/voxtype-* + releases/${{ steps.version.outputs.version }}/SHA256SUMS.txt diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml new file mode 100644 index 00000000..51ae0a1b --- /dev/null +++ b/.github/workflows/build-macos.yml @@ -0,0 +1,172 @@ +name: Build macOS + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + version: + description: 'Version (without v prefix)' + required: true + default: '0.5.0' + +jobs: + build: + runs-on: macos-14 # Apple Silicon runner for ARM64 build + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Install Rust targets + run: | + rustup target add x86_64-apple-darwin + rustup target add aarch64-apple-darwin + + - name: Determine version + id: version + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + else + echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + fi + + - name: Build for x86_64 + run: | + cargo build --release --target x86_64-apple-darwin --features gpu-metal + + - name: Build for aarch64 + run: | + cargo build --release --target aarch64-apple-darwin --features gpu-metal + + - name: Create universal binary + run: | + VERSION=${{ steps.version.outputs.version }} + mkdir -p releases/${VERSION} + lipo -create \ + target/x86_64-apple-darwin/release/voxtype \ + target/aarch64-apple-darwin/release/voxtype \ + -output releases/${VERSION}/voxtype-${VERSION}-macos-universal + chmod +x releases/${VERSION}/voxtype-${VERSION}-macos-universal + + - name: Verify universal binary + run: | + VERSION=${{ steps.version.outputs.version }} + lipo -info releases/${VERSION}/voxtype-${VERSION}-macos-universal + releases/${VERSION}/voxtype-${VERSION}-macos-universal --version + + - name: Import certificate + if: env.APPLE_DEVELOPER_ID_CERT != '' + env: + APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }} + APPLE_DEVELOPER_ID_CERT_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_CERT_PASSWORD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # Import certificate + echo "$APPLE_DEVELOPER_ID_CERT" | base64 --decode > certificate.p12 + security import certificate.p12 -P "$APPLE_DEVELOPER_ID_CERT_PASSWORD" \ + -A -t cert -f pkcs12 -k $KEYCHAIN_PATH + rm certificate.p12 + + # Add keychain to search list + security list-keychain -d user -s $KEYCHAIN_PATH + + # Allow codesign to access key + security set-key-partition-list -S apple-tool:,apple:,codesign: \ + -s -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + - name: Sign binary + if: env.APPLE_DEVELOPER_ID_CERT != '' + env: + APPLE_DEVELOPER_ID_CERT: ${{ secrets.APPLE_DEVELOPER_ID_CERT }} + run: | + VERSION=${{ steps.version.outputs.version }} + BINARY=releases/${VERSION}/voxtype-${VERSION}-macos-universal + + # Find the signing identity + IDENTITY=$(security find-identity -v -p codesigning | \ + grep "Developer ID Application" | head -1 | \ + sed 's/.*"\(.*\)".*/\1/') + + codesign --deep --force --verify --verbose \ + --sign "$IDENTITY" \ + --timestamp \ + --options runtime \ + "$BINARY" + + codesign --verify --strict --verbose=2 "$BINARY" + + - name: Notarize binary + if: env.APPLE_ID != '' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + VERSION=${{ steps.version.outputs.version }} + BINARY=releases/${VERSION}/voxtype-${VERSION}-macos-universal + + # Create ZIP for notarization + ditto -c -k "$BINARY" "${BINARY}.zip" + + # Submit for notarization + xcrun notarytool submit "${BINARY}.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + # Clean up and staple + rm "${BINARY}.zip" + xcrun stapler staple "$BINARY" + + - name: Create DMG + run: | + VERSION=${{ steps.version.outputs.version }} + BINARY=releases/${VERSION}/voxtype-${VERSION}-macos-universal + DMG=releases/${VERSION}/voxtype-${VERSION}-macos-universal.dmg + + # Create temp directory with binary + TEMP_DIR=$(mktemp -d) + cp "$BINARY" "$TEMP_DIR/voxtype" + + # Create simple DMG + hdiutil create -volname "Voxtype $VERSION" \ + -srcfolder "$TEMP_DIR" \ + -ov -format UDZO \ + "$DMG" + + rm -rf "$TEMP_DIR" + + - name: Generate checksums + run: | + VERSION=${{ steps.version.outputs.version }} + cd releases/${VERSION} + shasum -a 256 * > SHA256SUMS.txt + cat SHA256SUMS.txt + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-release + path: | + releases/${{ steps.version.outputs.version }}/voxtype-* + releases/${{ steps.version.outputs.version }}/SHA256SUMS.txt + + - name: Upload to release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v1 + with: + files: | + releases/${{ steps.version.outputs.version }}/voxtype-* + releases/${{ steps.version.outputs.version }}/SHA256SUMS.txt diff --git a/Cargo.lock b/Cargo.lock index 82913199..6a13fe0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,30 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", ] [[package]] @@ -161,7 +184,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.111", ] [[package]] @@ -179,7 +202,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn", + "syn 2.0.111", ] [[package]] @@ -193,6 +216,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "bitvec" @@ -206,6 +232,21 @@ dependencies = [ "wyz", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -224,6 +265,31 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.10.0", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "castaway" version = "0.2.4" @@ -260,6 +326,16 @@ dependencies = [ "nom", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -311,10 +387,10 @@ version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -342,6 +418,21 @@ dependencies = [ "cc", ] +[[package]] +name = "cocoa" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667fdc068627a2816b9ff831201dd9864249d6ee8d190b9532357f1fc0f61ea7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics 0.21.0", + "foreign-types 0.3.2", + "libc", + "objc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -373,22 +464,96 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "core-foundation" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d24c7a13c43e870e37c1556b74555437870a04514f7685f5b354e090567171" +dependencies = [ + "core-foundation-sys 0.7.0", + "libc", +] + [[package]] name = "core-foundation" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a71ab494c0b5b860bdc8407ae08978052417070c2ced38573a9157ad75b8ac" + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3889374e6ea6ab25dba90bb5d96202f61108058361f6dc72e8b03e6f8bbe923" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.7.0", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a67c4378cf203eace8fb6567847eb641fd6ff933c1145a115c6ee820ebb978" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "foreign-types 0.3.2", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -396,7 +561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "321077172d79c662f64f5071a03120748d5bb652f5231570141be24cfcd2bace" dependencies = [ "bitflags 1.3.2", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "coreaudio-sys", ] @@ -416,20 +581,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", - "core-foundation-sys", + "core-foundation-sys 0.8.7", "coreaudio-rs", "dasp_sample", "jni", "js-sys", "libc", "mach2", - "ndk", + "ndk 0.8.0", "ndk-context", "oboe", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows", + "windows 0.54.0", ] [[package]] @@ -496,7 +661,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.111", ] [[package]] @@ -507,7 +672,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -553,7 +718,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -563,7 +728,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.111", ] [[package]] @@ -572,7 +737,25 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", ] [[package]] @@ -583,10 +766,38 @@ checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.4.6", "windows-sys 0.48.0", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -595,9 +806,38 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] +[[package]] +name = "dlopen2" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1297103d2bbaea85724fcee6294c2d50b1081f9ad47d0f6f6f61eda65315a6" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "either" version = "1.15.0" @@ -661,6 +901,25 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset 0.9.1", + "rustc_version", +] + [[package]] name = "filetime" version = "0.2.26" @@ -701,7 +960,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared 0.3.1", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -710,6 +990,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -740,12 +1026,154 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -769,18 +1197,166 @@ dependencies = [ "wasip2", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.10.0", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -1050,6 +1626,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.10.0", + "serde", + "unicode-segmentation", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -1076,12 +1663,46 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading 0.7.4", + "once_cell", +] + [[package]] name = "libc" version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -1089,7 +1710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1099,7 +1720,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1113,6 +1734,25 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1171,6 +1811,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1205,6 +1854,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1263,7 +1921,28 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "muda" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c1738382f66ed56b3b9c8119e794a2e23148ac8ea214eda86622d4cb9d415a" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.17", + "windows-sys 0.60.2", ] [[package]] @@ -1307,11 +1986,26 @@ dependencies = [ "bitflags 2.10.0", "jni-sys", "log", - "ndk-sys", + "ndk-sys 0.5.0+25.2.9519653", "num_enum", "thiserror 1.0.69", ] +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys 0.6.0+11769913", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + [[package]] name = "ndk-context" version = "0.1.1" @@ -1327,6 +2021,15 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + [[package]] name = "nix" version = "0.23.2" @@ -1337,7 +2040,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "memoffset", + "memoffset 0.6.5", ] [[package]] @@ -1407,7 +2110,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1449,15 +2152,84 @@ dependencies = [ ] [[package]] -name = "num_enum_derive" -version = "0.7.5" +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro-crate 3.4.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.10.0", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", + "bitflags 2.10.0", + "block2", + "objc2", + "objc2-core-foundation", ] [[package]] @@ -1467,7 +2239,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb" dependencies = [ "jni", - "ndk", + "ndk 0.8.0", "ndk-context", "num-derive", "num-traits", @@ -1525,7 +2297,7 @@ checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -1540,7 +2312,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1592,6 +2364,31 @@ dependencies = [ "ureq 3.1.4", ] +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parakeet-rs" version = "0.2.9" @@ -1628,7 +2425,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1669,12 +2466,31 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "portable-atomic" version = "1.13.0" @@ -1715,7 +2531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.111", ] [[package]] @@ -1727,6 +2543,26 @@ dependencies = [ "num-integer", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1736,6 +2572,30 @@ dependencies = [ "toml_edit 0.23.7", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -1795,6 +2655,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "rawpointer" version = "0.2.1" @@ -1832,6 +2698,22 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "rdev" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00552ca2dc2f93b84cd7b5581de49549411e4e41d89e1c691bcb93dc4be360c3" +dependencies = [ + "cocoa", + "core-foundation 0.7.0", + "core-foundation-sys 0.7.0", + "core-graphics 0.19.2", + "lazy_static", + "libc", + "winapi", + "x11", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1852,6 +2734,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror 2.0.17", +] + [[package]] name = "regex" version = "1.12.2" @@ -1918,6 +2811,15 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustfft" version = "6.4.1" @@ -2023,8 +2925,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", - "core-foundation-sys", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", "libc", "security-framework-sys", ] @@ -2035,10 +2937,16 @@ version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ - "core-foundation-sys", + "core-foundation-sys 0.8.7", "libc", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -2066,7 +2974,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2121,6 +3029,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -2190,6 +3104,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.111" @@ -2209,7 +3133,70 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63c8b1020610b9138dd7b1e06cf259ae91aa05c30f3bd0d6b42a03997b92dec1" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "crossbeam-channel", + "dispatch", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "lazy_static", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "scopeguard", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.60.0", + "windows-core 0.60.1", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -2218,6 +3205,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.23.0" @@ -2257,7 +3250,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2268,7 +3261,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2348,26 +3341,26 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "toml" -version = "0.8.23" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" dependencies = [ "serde", "serde_spanned", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", ] [[package]] name = "toml_datetime" -version = "0.6.11" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" dependencies = [ "serde", ] @@ -2383,16 +3376,26 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ "indexmap", "serde", "serde_spanned", - "toml_datetime 0.6.11", - "toml_write", - "winnow", + "toml_datetime 0.6.3", + "winnow 0.5.40", ] [[package]] @@ -2404,7 +3407,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.14", ] [[package]] @@ -2413,15 +3416,9 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.14", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tracing" version = "0.1.41" @@ -2441,7 +3438,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2493,6 +3490,27 @@ dependencies = [ "strength_reduce", ] +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.17", + "windows-sys 0.60.2", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2616,6 +3634,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -2630,8 +3654,11 @@ dependencies = [ "async-trait", "clap", "clap_mangen", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "cpal", "directories", + "dirs 5.0.1", "evdev", "hound", "inotify 0.10.2", @@ -2641,16 +3668,20 @@ dependencies = [ "num_cpus", "parakeet-rs", "pidlock", + "rdev", "regex", "rodio", + "semver", "serde", "serde_json", + "tao", "tempfile", "thiserror 1.0.69", "tokio", "toml", "tracing", "tracing-subscriber", + "tray-icon", "ureq 2.12.1", "which", "whisper-rs", @@ -2726,7 +3757,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "wasm-bindgen-shared", ] @@ -2847,26 +3878,109 @@ version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" dependencies = [ - "windows-core", + "windows-core 0.54.0", "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" +dependencies = [ + "windows-collections", + "windows-core 0.60.1", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" +dependencies = [ + "windows-core 0.60.1", +] + [[package]] name = "windows-core" version = "0.54.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" dependencies = [ - "windows-result", + "windows-result 0.1.2", "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.60.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" +dependencies = [ + "windows-core 0.60.1", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-implement" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" +dependencies = [ + "windows-core 0.60.1", + "windows-link 0.1.3", +] + [[package]] name = "windows-result" version = "0.1.2" @@ -2876,6 +3990,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -2918,7 +4050,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -2973,7 +4105,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -2984,6 +4116,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -3164,6 +4305,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "0.7.14" @@ -3200,6 +4350,27 @@ dependencies = [ "tap", ] +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "xtask" version = "0.1.0" @@ -3226,7 +4397,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -3247,7 +4418,7 @@ checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3267,7 +4438,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -3307,7 +4478,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6157a8ab..c91ba708 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } toml = "0.8" directories = "5" +dirs = "5" # Logging tracing = "0.1" @@ -38,11 +39,12 @@ regex = "1" # Async traits async-trait = "0.1" -# Input handling (evdev for kernel-level key events) -evdev = "0.12" +# Common dependencies libc = "0.2" -inotify = "0.10" # Watch /dev/input for device hotplug -nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC + +# Menu bar and global hotkeys (cross-platform) +rdev = "0.5.3" +tray-icon = "0.21.3" # Audio capture cpal = "0.15" @@ -54,6 +56,9 @@ ureq = { version = "2", features = ["json"] } # JSON parsing (for CLI backend) serde_json = "1" +# Version comparison for update checking +semver = "1" + # CLI path resolution (for CLI backend) which = "7" @@ -79,6 +84,17 @@ notify = "6" # Single instance check pidlock = "0.1" +[target.'cfg(target_os = "macos")'.dependencies] +tao = "0.32" +core-graphics = "0.24" +core-foundation = "0.10" + +[target.'cfg(target_os = "linux")'.dependencies] +# Input handling (evdev for kernel-level key events) +evdev = "0.12" +inotify = "0.10" # Watch /dev/input for device hotplug +nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC + [features] default = [] gpu-vulkan = ["whisper-rs/vulkan"] diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ff2faba9a557c7d08ea8c3d6b6e971bc0f944e73 GIT binary patch literal 3554 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 K){=73JNZAgW~y)i literal 0 HcmV?d00001 diff --git a/config/default.toml b/config/default.toml index dad09743..0c2c8677 100644 --- a/config/default.toml +++ b/config/default.toml @@ -106,12 +106,6 @@ on_demand_loading = false # remote_api_key = "sk-..." # Or use VOXTYPE_WHISPER_API_KEY env var # remote_timeout_secs = 30 -# --- CLI mode settings (used when mode = "cli") --- -# Uses whisper-cli subprocess instead of whisper-rs FFI bindings. -# Fallback for systems where whisper-rs crashes (e.g., glibc 2.42+ on Ubuntu 25.10). -# Requires whisper-cli from whisper.cpp: https://github.com/ggerganov/whisper.cpp -# whisper_cli_path = "/usr/local/bin/whisper-cli" # Optional, searches PATH if not set - # [parakeet] # Parakeet configuration (only used when engine = "parakeet") # Requires: cargo build --features parakeet diff --git a/contrib/hammerspoon/README.md b/contrib/hammerspoon/README.md new file mode 100644 index 00000000..83103eef --- /dev/null +++ b/contrib/hammerspoon/README.md @@ -0,0 +1,73 @@ +# Voxtype Hammerspoon Integration + +Hammerspoon integration for voxtype on macOS. This is an alternative to the built-in hotkey support that doesn't require granting Accessibility permissions to Terminal. + +## Installation + +1. Install Hammerspoon: + ```bash + brew install --cask hammerspoon + ``` + +2. Copy the voxtype module: + ```bash + cp voxtype.lua ~/.hammerspoon/ + ``` + +3. Add to your `~/.hammerspoon/init.lua`: + ```lua + local voxtype = require("voxtype") + voxtype.setup({ hotkey = "rightalt" }) + ``` + +4. Reload Hammerspoon config (Cmd+Shift+R or click menu bar icon → Reload Config) + +## Configuration + +```lua +voxtype.setup({ + -- Key to use for push-to-talk + -- Options: "rightalt", "rightcmd", "f13", "f14", etc. + hotkey = "rightalt", + + -- Mode: "push_to_talk" or "toggle" + -- push_to_talk: Hold key to record, release to transcribe + -- toggle: Press once to start, press again to stop + mode = "push_to_talk", + + -- Path to voxtype binary (optional, auto-detected) + voxtype_path = nil, +}) +``` + +## Adding a Cancel Hotkey + +You can add a separate hotkey to cancel recording: + +```lua +voxtype.add_cancel_hotkey({"cmd", "shift"}, "escape") +``` + +## Checking Status + +```lua +print(voxtype.status()) -- Returns: "idle", "recording", "transcribing", or "stopped" +``` + +## Why Use Hammerspoon? + +- **No Accessibility permissions for Terminal**: The built-in rdev hotkey requires granting Accessibility access to your terminal app +- **More flexible hotkey options**: Hammerspoon supports complex key combinations +- **Integration with other automations**: Combine voxtype with your other Hammerspoon workflows +- **Visual feedback**: Easy to add custom alerts and notifications + +## Troubleshooting + +**Hotkey not working?** +- Make sure Hammerspoon has Accessibility permissions (System Settings → Privacy & Security → Accessibility) +- Check the Hammerspoon console for errors (click menu bar icon → Console) +- Verify voxtype daemon is running: `voxtype status` + +**voxtype not found?** +- Set the path explicitly: `voxtype.setup({ voxtype_path = "/path/to/voxtype" })` +- Or add voxtype to your PATH diff --git a/contrib/hammerspoon/voxtype.lua b/contrib/hammerspoon/voxtype.lua new file mode 100644 index 00000000..14d7062f --- /dev/null +++ b/contrib/hammerspoon/voxtype.lua @@ -0,0 +1,188 @@ +-- Voxtype Hammerspoon Integration +-- +-- This module provides hotkey support for voxtype on macOS using Hammerspoon. +-- It's an alternative to the built-in rdev hotkey capture that doesn't require +-- granting Accessibility permissions to Terminal. +-- +-- Installation: +-- 1. Install Hammerspoon: brew install --cask hammerspoon +-- 2. Copy this file to ~/.hammerspoon/voxtype.lua +-- 3. Add to your ~/.hammerspoon/init.lua: +-- local voxtype = require("voxtype") +-- voxtype.setup({ hotkey = "rightalt" }) -- or your preferred key +-- 4. Reload Hammerspoon config (Cmd+Shift+R or click menu bar icon) +-- +-- Configuration options: +-- hotkey: The key to use for push-to-talk (default: "rightalt") +-- Common choices: "rightalt", "rightcmd", "f13", "f14", etc. +-- mode: "push_to_talk" (hold to record) or "toggle" (press to start/stop) +-- voxtype_path: Path to voxtype binary (default: auto-detect) + +local M = {} + +-- Default configuration +M.config = { + hotkey = "rightalt", + mode = "push_to_talk", + voxtype_path = nil, -- Auto-detect +} + +-- State +M.is_recording = false +M.hotkey_binding = nil + +-- Find voxtype binary +local function find_voxtype() + if M.config.voxtype_path then + return M.config.voxtype_path + end + + -- Common installation paths + local paths = { + "/opt/homebrew/bin/voxtype", + "/usr/local/bin/voxtype", + os.getenv("HOME") .. "/.cargo/bin/voxtype", + os.getenv("HOME") .. "/workspace/voxtype/target/release/voxtype", + } + + for _, path in ipairs(paths) do + if hs.fs.attributes(path) then + return path + end + end + + -- Try which + local handle = io.popen("which voxtype 2>/dev/null") + if handle then + local result = handle:read("*a"):gsub("%s+", "") + handle:close() + if result ~= "" then + return result + end + end + + return nil +end + +-- Execute voxtype command +local function voxtype_cmd(cmd) + local path = find_voxtype() + if not path then + hs.alert.show("voxtype not found!") + return + end + + hs.task.new(path, nil, {"record", cmd}):start() +end + +-- Start recording +local function start_recording() + if not M.is_recording then + M.is_recording = true + voxtype_cmd("start") + -- Optional: show visual feedback + -- hs.alert.show("🎤 Recording...", 0.5) + end +end + +-- Stop recording +local function stop_recording() + if M.is_recording then + M.is_recording = false + voxtype_cmd("stop") + end +end + +-- Toggle recording +local function toggle_recording() + if M.is_recording then + stop_recording() + else + start_recording() + end +end + +-- Cancel recording +local function cancel_recording() + if M.is_recording then + M.is_recording = false + voxtype_cmd("cancel") + hs.alert.show("Recording cancelled", 0.5) + end +end + +-- Map key name to Hammerspoon key +local function map_key(key) + local keymap = { + rightalt = "rightalt", + rightoption = "rightalt", + rightopt = "rightalt", + leftalt = "alt", + leftoption = "alt", + leftopt = "alt", + rightcmd = "rightcmd", + rightcommand = "rightcmd", + leftcmd = "cmd", + leftcommand = "cmd", + rightctrl = "rightctrl", + rightcontrol = "rightctrl", + leftctrl = "ctrl", + leftcontrol = "ctrl", + rightshift = "rightshift", + leftshift = "shift", + } + + local lower = key:lower() + return keymap[lower] or lower +end + +-- Setup voxtype hotkey +function M.setup(opts) + opts = opts or {} + + -- Merge config + for k, v in pairs(opts) do + M.config[k] = v + end + + -- Remove existing binding + if M.hotkey_binding then + M.hotkey_binding:delete() + end + + local key = map_key(M.config.hotkey) + + if M.config.mode == "toggle" then + -- Toggle mode: single press to start/stop + M.hotkey_binding = hs.hotkey.bind({}, key, toggle_recording) + else + -- Push-to-talk mode: hold to record, release to stop + M.hotkey_binding = hs.hotkey.bind({}, key, start_recording, stop_recording) + end + + print("Voxtype: Hotkey '" .. key .. "' bound in " .. M.config.mode .. " mode") +end + +-- Add cancel hotkey (optional) +function M.add_cancel_hotkey(mods, key) + hs.hotkey.bind(mods, key, cancel_recording) + print("Voxtype: Cancel hotkey bound to " .. table.concat(mods, "+") .. "+" .. key) +end + +-- Status check +function M.status() + local path = find_voxtype() + if not path then + return "voxtype not found" + end + + local handle = io.popen(path .. " status 2>/dev/null") + if handle then + local result = handle:read("*a"):gsub("%s+", "") + handle:close() + return result + end + return "unknown" +end + +return M diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index caf5b342..c0b068d3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -360,30 +360,18 @@ Controls the Whisper speech-to-text engine. Selects the transcription backend. **Values:** -- `local` - Use whisper.cpp locally via FFI bindings (default, fully offline) +- `local` - Use whisper.cpp locally on your machine (default, fully offline) - `remote` - Send audio to a remote server for transcription -- `cli` - Use whisper-cli subprocess (fallback for systems where FFI crashes) > **Privacy Notice**: When using `remote` backend, audio is transmitted over the network. See [User Manual - Remote Whisper Servers](USER_MANUAL.md#remote-whisper-servers) for privacy considerations. -**When to use `cli` backend:** -The `cli` backend is a workaround for systems where the whisper-rs FFI bindings crash due to C++ exceptions crossing the FFI boundary. This affects some systems with glibc 2.42+ (e.g., Ubuntu 25.10). If voxtype crashes during transcription, try the `cli` backend. - -Requires `whisper-cli` from [whisper.cpp](https://github.com/ggerganov/whisper.cpp). - -**Examples:** +**Example:** ```toml [whisper] backend = "remote" remote_endpoint = "http://192.168.1.100:8080" ``` -```toml -[whisper] -backend = "cli" -whisper_cli_path = "/usr/local/bin/whisper-cli" # Optional -``` - ### model **Type:** String @@ -838,39 +826,6 @@ remote_endpoint = "http://192.168.1.100:8080" remote_timeout_secs = 60 # 60 second timeout for long recordings ``` -### whisper_cli_path - -**Type:** String -**Default:** Auto-detected from PATH -**Required:** No - -Path to the `whisper-cli` binary. Only used when `backend = "cli"`. - -If not specified, voxtype searches for `whisper-cli` or `whisper` in: -1. Your `$PATH` -2. Common system locations (`/usr/local/bin`, `/usr/bin`) -3. Current directory -4. `~/.local/bin` - -**Example:** -```toml -[whisper] -backend = "cli" -whisper_cli_path = "/opt/whisper.cpp/build/bin/whisper-cli" -``` - -**Installing whisper-cli:** - -Build from source at [github.com/ggerganov/whisper.cpp](https://github.com/ggerganov/whisper.cpp): - -```bash -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp -cmake -B build -cmake --build build --config Release -sudo cp build/bin/whisper-cli /usr/local/bin/ -``` - --- ## [parakeet] diff --git a/docs/INSTALL.md b/docs/INSTALL.md index a2ea9ebc..0af9444c 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -346,12 +346,10 @@ sudo pacman -S ydotool # Ubuntu: sudo apt install ydotool -# Enable and start the daemon (Arch) +# Enable and start the daemon systemctl --user enable --now ydotool ``` -> **Note (Fedora):** Fedora's ydotool uses a system service that requires additional configuration. See [Troubleshooting - ydotool daemon not running](TROUBLESHOOTING.md#ydotool-daemon-not-running) for Fedora-specific setup. - **On KDE Plasma or GNOME (Wayland):** wtype does not work on these desktops because they don't support the virtual keyboard protocol. Install dotool (recommended) or use ydotool: For dotool (recommended, supports keyboard layouts): @@ -366,8 +364,7 @@ For ydotool: ```bash # Install ydotool (see commands above for your distro) # Then enable and start the daemon (required!) -systemctl --user enable --now ydotool # Arch -# For Fedora, see Troubleshooting guide for system service setup +systemctl --user enable --now ydotool ``` Voxtype uses wtype on Wayland (no daemon needed), with dotool and ydotool as fallbacks, and clipboard as the last resort. On KDE/GNOME Wayland, wtype will fail and voxtype will use dotool or ydotool. diff --git a/docs/INSTALL_MACOS.md b/docs/INSTALL_MACOS.md new file mode 100644 index 00000000..c9bf931e --- /dev/null +++ b/docs/INSTALL_MACOS.md @@ -0,0 +1,213 @@ +# Voxtype macOS Installation Guide + +Voxtype is a push-to-talk voice-to-text tool that uses Whisper for fast, local speech recognition. + +## Requirements + +- macOS 11 (Big Sur) or later +- Apple Silicon (M1/M2/M3) or Intel Mac +- Accessibility permissions for global hotkey detection + +## Installation + +### Option 1: Homebrew (Recommended) + +```bash +# Add the tap +brew tap peteonrails/voxtype + +# Install +brew install --cask voxtype +``` + +### Option 2: Direct Download + +1. Download the latest DMG from [GitHub Releases](https://github.com/peteonrails/voxtype/releases) +2. Open the DMG and drag `voxtype` to `/usr/local/bin` + +```bash +# Or install via command line +curl -L https://github.com/peteonrails/voxtype/releases/latest/download/voxtype-macos-universal.dmg -o voxtype.dmg +hdiutil attach voxtype.dmg +cp /Volumes/Voxtype/voxtype /usr/local/bin/ +hdiutil detach /Volumes/Voxtype +rm voxtype.dmg +``` + +### Option 3: Build from Source + +```bash +git clone https://github.com/peteonrails/voxtype.git +cd voxtype +cargo build --release --features gpu-metal +cp target/release/voxtype /usr/local/bin/ +``` + +## Setup + +### 1. Grant Accessibility Permissions + +Voxtype needs Accessibility permissions to detect global hotkeys. + +1. Open **System Preferences** (or System Settings on macOS 13+) +2. Go to **Privacy & Security** > **Accessibility** +3. Click the lock icon to make changes +4. Add and enable `voxtype` (or Terminal if running from terminal) + +### 2. Download a Whisper Model + +```bash +# Interactive model selection +voxtype setup model + +# Or download a specific model +voxtype setup --download --model base.en +``` + +Available models: +- `tiny.en` / `tiny` - Fastest, lowest accuracy (39 MB) +- `base.en` / `base` - Good balance, recommended (142 MB) +- `small.en` / `small` - Better accuracy (466 MB) +- `medium.en` / `medium` - High accuracy (1.5 GB) +- `large-v3` - Best accuracy (3.1 GB) **Pro only** +- `large-v3-turbo` - Fast + accurate (1.6 GB) **Pro only** + +### 3. Configure Hotkey + +Edit `~/.config/voxtype/config.toml`: + +```toml +[hotkey] +key = "F13" # Or any key: SCROLLLOCK, PAUSE, etc. +modifiers = [] # Optional: ["CTRL"], ["CMD"], etc. +mode = "push_to_talk" # Or "toggle" +``` + +### 4. Start Voxtype + +**Manual start:** +```bash +voxtype daemon +``` + +**Auto-start on login (recommended):** +```bash +voxtype setup launchd +``` + +## Usage + +1. Hold the hotkey (default: F13) to record +2. Speak your text +3. Release the hotkey to transcribe +4. Text is typed into the active window + +### Quick Reference + +```bash +voxtype daemon # Start the daemon +voxtype status # Check if daemon is running +voxtype setup model # Download/switch models +voxtype setup launchd # Install as LaunchAgent +voxtype check-update # Check for updates +voxtype --help # Show all options +``` + +## Troubleshooting + +### "Accessibility permissions required" + +1. Check System Preferences > Privacy & Security > Accessibility +2. Ensure voxtype (or Terminal) is added and enabled +3. Try removing and re-adding the app + +### Hotkey not working + +1. Check that the hotkey isn't used by another app +2. Try a different key (F13, SCROLLLOCK, PAUSE are good choices) +3. Ensure Accessibility permissions are granted + +### "Model not found" + +```bash +voxtype setup model # Download a model +``` + +### Daemon not starting + +```bash +# Check logs +tail -f ~/Library/Logs/voxtype/stderr.log + +# Verify permissions +ls -la /usr/local/bin/voxtype +``` + +### LaunchAgent issues + +```bash +# Check status +launchctl list | grep voxtype + +# View logs +tail -f ~/Library/Logs/voxtype/stdout.log + +# Reload service +launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist +launchctl load ~/Library/LaunchAgents/io.voxtype.daemon.plist +``` + +## Uninstalling + +### Homebrew + +```bash +brew uninstall --cask voxtype +``` + +### Manual + +```bash +# Stop and remove LaunchAgent +launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist +rm ~/Library/LaunchAgents/io.voxtype.daemon.plist + +# Remove binary +rm /usr/local/bin/voxtype + +# Remove config and data (optional) +rm -rf ~/.config/voxtype +rm -rf ~/.local/share/voxtype +rm -rf ~/Library/Logs/voxtype +``` + +## Configuration + +Config file: `~/.config/voxtype/config.toml` + +```toml +[hotkey] +key = "F13" +modifiers = [] +mode = "push_to_talk" # or "toggle" + +[audio] +device = "default" +sample_rate = 16000 +max_duration_secs = 60 + +[whisper] +model = "base.en" +language = "en" + +[output] +mode = "type" # or "clipboard", "paste" +``` + +See [CONFIGURATION.md](CONFIGURATION.md) for full options. + +## Getting Help + +- GitHub Issues: https://github.com/peteonrails/voxtype/issues +- Documentation: https://voxtype.io/docs +- Email: support@voxtype.io diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 0bd2c0cb..19f85de7 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -10,8 +10,6 @@ Solutions to common issues when using Voxtype. - [Transcription Issues](#transcription-issues) - [Output Problems](#output-problems) - [wtype not working on KDE Plasma or GNOME Wayland](#wtype-not-working-on-kde-plasma-or-gnome-wayland) - - [Text output not working on X11](#text-output-not-working-on-x11) - - [Wrong characters on non-US keyboard layouts](#wrong-characters-on-non-us-keyboard-layouts-yz-swapped-qwertz-azerty) - [Performance Issues](#performance-issues) - [Systemd Service Issues](#systemd-service-issues) - [Debug Mode](#debug-mode) @@ -233,29 +231,6 @@ curl -L -o ~/.local/share/voxtype/models/ggml-base.en.bin \ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin ``` -### Voxtype crashes during transcription - -**Cause:** On some systems (particularly with glibc 2.42+ like Ubuntu 25.10), the whisper-rs FFI bindings crash due to C++ exceptions crossing the FFI boundary. - -**Solution:** Use the CLI backend which runs whisper-cli as a subprocess: - -```toml -[whisper] -backend = "cli" -``` - -This requires `whisper-cli` to be installed. Build it from [whisper.cpp](https://github.com/ggerganov/whisper.cpp): - -```bash -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp -cmake -B build -cmake --build build --config Release -sudo cp build/bin/whisper-cli /usr/local/bin/ -``` - -See [CLI Backend](USER_MANUAL.md#cli-backend-whisper-cli) in the User Manual for details. - ### Poor transcription accuracy **Possible causes:** @@ -399,204 +374,22 @@ mode = "paste" # Copies to clipboard, then simulates Ctrl+V --- -### Text output not working on X11 - -**Symptom:** You're running X11 (not Wayland) and see errors like: -``` -WARN wtype failed: Wayland connection failed -WARN clipboard (wl-copy) failed: Text injection failed -ERROR Output failed: All output methods failed. -``` - -**Cause:** wtype and wl-copy are Wayland-only tools. On X11, voxtype needs dotool, ydotool, or xclip installed. - -**Solution:** Install one of these X11-compatible tools: - -**Option 1 (Recommended): Install dotool** - -dotool works on X11, supports keyboard layouts, and doesn't need a daemon: - -```bash -# Ubuntu/Debian (from source): -sudo apt install libxkbcommon-dev -git clone https://git.sr.ht/~geb/dotool -cd dotool && ./build.sh && sudo cp dotool /usr/local/bin/ - -# Arch (AUR): -yay -S dotool - -# Add user to input group -sudo usermod -aG input $USER -# Log out and back in -``` - -**Option 2: Install ydotool** - -ydotool works on X11 but requires a running daemon: - -```bash -# Ubuntu/Debian: -sudo apt install ydotool - -# Start the daemon (see "ydotool daemon not running" section for Fedora) -systemctl --user enable --now ydotool -``` - -**Option 3: Use clipboard mode with xclip** - -For clipboard-only output (you paste manually with Ctrl+V): - -```bash -# Ubuntu/Debian: -sudo apt install xclip -``` - -Then configure voxtype to use clipboard mode: -```toml -[output] -mode = "clipboard" -``` - -**Verify your setup:** - -```bash -voxtype setup -``` - -This shows which output tools are installed and available. - ---- - -### Wrong characters on non-US keyboard layouts (y/z swapped, QWERTZ, AZERTY) - -**Symptom:** Transcribed text has wrong characters. For example, on a German keyboard, "Python" becomes "Pzthon" and "zebra" becomes "yebra" (y and z are swapped). - -**Cause:** ydotool sends raw US keycodes and doesn't support keyboard layouts. When voxtype falls back to ydotool (e.g., on X11, Cinnamon, or when wtype fails), characters are typed as if you had a US keyboard layout. - -**Solution:** Install dotool and configure your keyboard layout. Unlike ydotool, dotool supports keyboard layouts via XKB: - -```bash -# 1. Install dotool -# Arch (AUR): -yay -S dotool -# Ubuntu/Debian (from source): -# See https://sr.ht/~geb/dotool/ for instructions -# Fedora (from source): -# See https://sr.ht/~geb/dotool/ for instructions - -# 2. Add user to input group (required for uinput access) -sudo usermod -aG input $USER -# Log out and back in for group change to take effect - -# 3. Configure your keyboard layout in config.toml: -``` - -Add to `~/.config/voxtype/config.toml`: - -```toml -[output] -dotool_xkb_layout = "de" # German QWERTZ -``` - -Common layout codes: -- `de` - German (QWERTZ) -- `fr` - French (AZERTY) -- `es` - Spanish -- `uk` - Ukrainian -- `ru` - Russian -- `pl` - Polish -- `it` - Italian -- `pt` - Portuguese - -For layout variants (e.g., German without dead keys): - -```toml -[output] -dotool_xkb_layout = "de" -dotool_xkb_variant = "nodeadkeys" -``` - -**Alternative:** Use paste mode, which copies text to the clipboard and simulates Ctrl+V. This works regardless of keyboard layout: - -```toml -[output] -mode = "paste" -``` - -**Note:** The keyboard layout fix requires voxtype v0.5.0 or later. If you're on an older version, upgrade first. - ---- - ### "ydotool daemon not running" -**Cause:** ydotool systemd service not started, or configured incorrectly for your distribution. - -**Solution:** The setup varies by distribution: - -#### Arch Linux (user service) - -Arch provides a user-level service that runs in your session: +**Cause:** ydotool systemd service not started. +**Solution:** ```bash -# Enable and start ydotool as a user service +# Enable and start ydotool systemctl --user enable --now ydotool # Verify it's running systemctl --user status ydotool -``` - -#### Fedora (system service) - -Fedora provides a system-level service that requires additional configuration to work with your user: - -```bash -# 1. Enable and start the system service -sudo systemctl enable --now ydotool - -# 2. Edit the service to allow your user to access the socket -sudo systemctl edit ydotool -``` - -Add this content (replace `1000` with your user/group ID from `id -u` and `id -g`): - -```ini -[Service] -ExecStart= -ExecStart=/usr/bin/ydotoold --socket-path="/run/user/1000/.ydotool_socket" --socket-own="1000:1000" -``` - -Then restart: - -```bash -sudo systemctl restart ydotool - -# Verify it's running -systemctl status ydotool -``` - -#### Ubuntu/Debian - -Check which service type is available: - -```bash -# Check for user service -systemctl --user status ydotool -# If not found, check for system service -systemctl status ydotool +# Check for errors +journalctl --user -u ydotool ``` -If only a system service exists, follow the Fedora instructions above. - -#### Verify ydotool works - -```bash -# Test that ydotool can type -ydotool type "test" -``` - -If you see "Failed to connect to socket", the daemon isn't running or the socket permissions are wrong. - ### Text not typed / nothing happens **Possible causes:** diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 993e00b0..4d400f3a 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -16,7 +16,6 @@ Voxtype is a push-to-talk voice-to-text tool for Linux. Optimized for Wayland, w - [Improving Transcription Accuracy](#improving-transcription-accuracy) - [Whisper Models](#whisper-models) - [Remote Whisper Servers](#remote-whisper-servers) -- [CLI Backend (whisper-cli)](#cli-backend-whisper-cli) - [Output Modes](#output-modes) - [Post-Processing with LLMs](#post-processing-with-llms) - [Profiles](#profiles) @@ -890,69 +889,6 @@ voxtype -vv --- -## CLI Backend (whisper-cli) - -The CLI backend uses `whisper-cli` from whisper.cpp as a subprocess instead of the built-in whisper-rs FFI bindings. This is a fallback for systems where the FFI bindings crash. - -### When to Use CLI Backend - -Use the CLI backend if: - -1. **Voxtype crashes during transcription**: Some systems with glibc 2.42+ (e.g., Ubuntu 25.10) experience crashes due to C++ exceptions crossing the FFI boundary. whisper.cpp works fine when run as a standalone binary. - -2. **You want to use a custom whisper.cpp build**: If you've compiled whisper.cpp with specific optimizations or features not available in the bundled whisper-rs bindings. - -3. **Debugging transcription issues**: Running whisper-cli as a subprocess makes it easier to isolate and diagnose problems. - -### Setting Up CLI Backend - -1. Install whisper-cli from [whisper.cpp](https://github.com/ggerganov/whisper.cpp): - -```bash -git clone https://github.com/ggerganov/whisper.cpp -cd whisper.cpp -cmake -B build -cmake --build build --config Release -sudo cp build/bin/whisper-cli /usr/local/bin/ -``` - -2. Configure voxtype to use CLI backend: - -```toml -[whisper] -backend = "cli" -model = "base.en" -language = "en" - -# Optional: specify path if not in PATH -# whisper_cli_path = "/usr/local/bin/whisper-cli" -``` - -3. Restart the voxtype daemon: - -```bash -systemctl --user restart voxtype -``` - -### How It Works - -When using CLI backend, voxtype: - -1. Writes recorded audio to a temporary WAV file -2. Runs `whisper-cli` with the configured model and options -3. Parses the JSON output from whisper-cli -4. Cleans up temporary files - -This adds minimal overhead compared to the FFI approach since file I/O is fast on modern systems. - -### Limitations - -- Slightly higher latency than FFI (file I/O overhead) -- Requires separate whisper-cli installation -- No GPU isolation mode (whisper-cli manages its own GPU memory) - ---- - ## Output Modes ### Type Mode (Default) diff --git a/flake.nix b/flake.nix index cad7ede0..f87a5194 100644 --- a/flake.nix +++ b/flake.nix @@ -76,11 +76,12 @@ onnxruntimeCuda = pkgsUnfree.onnxruntime.override { cudaSupport = true; }; onnxruntimeRocm = pkgs.onnxruntime.override { rocmSupport = true; }; + # Base derivation for voxtype (unwrapped) mkVoxtypeUnwrapped = { pname ? "voxtype", features ? [], extraNativeBuildInputs ? [], extraBuildInputs ? [] }: pkgs.rustPlatform.buildRustPackage { inherit pname; - version = "0.5.0"; + version = "0.4.9"; src = ./.; cargoLock.lockFile = ./Cargo.lock; @@ -223,6 +224,7 @@ ]; }; + in { packages = { # Wrapped packages (ready to use, runtime deps in PATH) @@ -237,13 +239,11 @@ parakeet-cuda = wrapParakeet { onnxruntime = onnxruntimeCuda; pkg = parakeetCudaUnwrapped; }; parakeet-rocm = wrapParakeet { onnxruntime = onnxruntimeRocm; pkg = parakeetRocmUnwrapped; }; + # Unwrapped packages (for custom wrapping scenarios) voxtype-unwrapped = mkVoxtypeUnwrapped {}; voxtype-vulkan-unwrapped = vulkanUnwrapped; voxtype-rocm-unwrapped = rocmUnwrapped; - voxtype-parakeet-unwrapped = parakeetUnwrapped; - voxtype-parakeet-cuda-unwrapped = parakeetCudaUnwrapped; - voxtype-parakeet-rocm-unwrapped = parakeetRocmUnwrapped; }; # Development shell with all dependencies diff --git a/packaging/debian/voxtype.service b/packaging/debian/voxtype.service index a26b4ab2..6a22aaa6 100644 --- a/packaging/debian/voxtype.service +++ b/packaging/debian/voxtype.service @@ -14,12 +14,5 @@ RestartSec=5 # Note: User must be in 'input' group for evdev access # Before enabling this service, run: voxtype setup --download -# GPU Selection (for systems with multiple GPUs): -# Create ~/.config/systemd/user/voxtype.service.d/gpu.conf with: -# [Service] -# Environment="VOXTYPE_VULKAN_DEVICE=nvidia" -# Valid values: nvidia, amd, intel -# Run: voxtype setup gpu to see detected GPUs - [Install] WantedBy=graphical-session.target diff --git a/packaging/homebrew/voxtype.rb b/packaging/homebrew/voxtype.rb new file mode 100644 index 00000000..106e8bbe --- /dev/null +++ b/packaging/homebrew/voxtype.rb @@ -0,0 +1,63 @@ +# Homebrew Cask formula for Voxtype +# +# To use this cask: +# 1. Create a tap: brew tap peteonrails/voxtype +# 2. Install: brew install --cask voxtype +# +# Or install directly: +# brew install --cask peteonrails/voxtype/voxtype + +cask "voxtype" do + version "0.5.0" + sha256 "PLACEHOLDER_SHA256" + + url "https://github.com/peteonrails/voxtype/releases/download/v#{version}/voxtype-#{version}-macos-universal.dmg", + verified: "github.com/peteonrails/voxtype/" + name "Voxtype" + desc "Push-to-talk voice-to-text using Whisper" + homepage "https://voxtype.io" + + livecheck do + url :url + strategy :github_latest + end + + # Universal binary supports both Intel and Apple Silicon + depends_on macos: ">= :big_sur" + + binary "voxtype" + + postflight do + # Remind user about Accessibility permissions + ohai "Voxtype requires Accessibility permissions to detect hotkeys." + ohai "Grant access in: System Preferences > Privacy & Security > Accessibility" + ohai "" + ohai "Quick start:" + ohai " voxtype setup # Check dependencies, download model" + ohai " voxtype setup launchd # Install as LaunchAgent (auto-start)" + ohai " voxtype daemon # Start manually" + end + + uninstall launchctl: "io.voxtype.daemon" + + zap trash: [ + "~/Library/LaunchAgents/io.voxtype.daemon.plist", + "~/Library/Logs/voxtype", + "~/.config/voxtype", + "~/.local/share/voxtype", + ] + + caveats <<~EOS + Voxtype requires Accessibility permissions to detect global hotkeys. + + After installation: + 1. Open System Preferences > Privacy & Security > Accessibility + 2. Add and enable voxtype (or the Terminal app if running from terminal) + + To install as a LaunchAgent (auto-start on login): + voxtype setup launchd + + To start manually: + voxtype daemon + EOS +end diff --git a/packaging/systemd/voxtype.service b/packaging/systemd/voxtype.service index a26b4ab2..6a22aaa6 100644 --- a/packaging/systemd/voxtype.service +++ b/packaging/systemd/voxtype.service @@ -14,12 +14,5 @@ RestartSec=5 # Note: User must be in 'input' group for evdev access # Before enabling this service, run: voxtype setup --download -# GPU Selection (for systems with multiple GPUs): -# Create ~/.config/systemd/user/voxtype.service.d/gpu.conf with: -# [Service] -# Environment="VOXTYPE_VULKAN_DEVICE=nvidia" -# Valid values: nvidia, amd, intel -# Run: voxtype setup gpu to see detected GPUs - [Install] WantedBy=graphical-session.target diff --git a/scripts/build-macos-dmg.sh b/scripts/build-macos-dmg.sh new file mode 100755 index 00000000..b2fc3548 --- /dev/null +++ b/scripts/build-macos-dmg.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# +# Create a DMG installer for macOS +# +# Requires: +# - Universal binary already built and signed +# - create-dmg tool (brew install create-dmg) +# +# Usage: +# ./scripts/build-macos-dmg.sh 0.5.0 + +set -euo pipefail + +VERSION="${1:-}" + +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 VERSION" + echo "Example: $0 0.5.0" + exit 1 +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +BINARY="releases/${VERSION}/voxtype-${VERSION}-macos-universal" +DMG_PATH="releases/${VERSION}/voxtype-${VERSION}-macos-universal.dmg" + +if [[ ! -f "$BINARY" ]]; then + echo -e "${RED}Error: Binary not found: $BINARY${NC}" + echo "Run ./scripts/build-macos.sh $VERSION first" + exit 1 +fi + +echo -e "${GREEN}Creating DMG for voxtype ${VERSION}...${NC}" +echo "Binary: $BINARY" +echo + +# Check for create-dmg +if ! command -v create-dmg &> /dev/null; then + echo -e "${YELLOW}Installing create-dmg...${NC}" + brew install create-dmg +fi + +# Create temporary directory for DMG contents +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +# Copy binary +cp "$BINARY" "$TEMP_DIR/voxtype" +chmod +x "$TEMP_DIR/voxtype" + +# Create README +cat > "$TEMP_DIR/README.txt" << 'EOF' +Voxtype - Push-to-talk voice-to-text for macOS + +Installation: + 1. Drag 'voxtype' to /usr/local/bin or your preferred location + 2. Grant Accessibility permissions when prompted + 3. Run: voxtype setup launchd + +Quick Start: + voxtype daemon - Start the daemon + voxtype setup launchd - Install as LaunchAgent (auto-start) + voxtype setup model - Download/select Whisper model + voxtype --help - Show all options + +For more information, visit: https://voxtype.io +EOF + +# Remove existing DMG if present +rm -f "$DMG_PATH" + +# Create DMG +echo -e "${YELLOW}Creating DMG...${NC}" +create-dmg \ + --volname "Voxtype $VERSION" \ + --volicon "packaging/macos/icon.icns" \ + --background "packaging/macos/dmg-background.png" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "voxtype" 175 190 \ + --icon "README.txt" 425 190 \ + --hide-extension "voxtype" \ + --app-drop-link 425 190 \ + "$DMG_PATH" \ + "$TEMP_DIR" 2>/dev/null || { + # If create-dmg fails (e.g., missing background), create simple DMG + echo -e "${YELLOW}Creating simple DMG (no custom background)...${NC}" + hdiutil create -volname "Voxtype $VERSION" \ + -srcfolder "$TEMP_DIR" \ + -ov -format UDZO \ + "$DMG_PATH" + } + +# Get DMG size +SIZE=$(du -h "$DMG_PATH" | cut -f1) + +echo +echo -e "${GREEN}DMG created successfully!${NC}" +echo " DMG: $DMG_PATH" +echo " Size: $SIZE" + +# Generate checksum +echo +echo "SHA256 checksum:" +shasum -a 256 "$DMG_PATH" + +echo +echo "Next steps:" +echo " 1. Test the DMG by mounting it" +echo " 2. Upload to GitHub release" +echo " 3. Update Homebrew cask formula" diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 00000000..dbd536ce --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,89 @@ +#!/bin/bash +# +# Build universal binary for macOS (x86_64 + arm64) +# +# Usage: +# ./scripts/build-macos.sh 0.5.0 +# +# Outputs to releases/${VERSION}/voxtype-${VERSION}-macos-universal + +set -euo pipefail + +VERSION="${1:-}" + +if [[ -z "$VERSION" ]]; then + echo "Usage: $0 VERSION" + echo "Example: $0 0.5.0" + exit 1 +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}Building voxtype ${VERSION} for macOS...${NC}" +echo + +# Check we're on macOS +if [[ "$(uname)" != "Darwin" ]]; then + echo -e "${RED}Error: This script must be run on macOS${NC}" + exit 1 +fi + +# Ensure output directory exists +OUTPUT_DIR="releases/${VERSION}" +mkdir -p "$OUTPUT_DIR" + +# Add targets if not already installed +echo "Checking Rust targets..." +rustup target add x86_64-apple-darwin 2>/dev/null || true +rustup target add aarch64-apple-darwin 2>/dev/null || true + +# Build for x86_64 +echo +echo -e "${YELLOW}Building for x86_64-apple-darwin...${NC}" +cargo build --release --target x86_64-apple-darwin --features gpu-metal + +# Build for aarch64 +echo +echo -e "${YELLOW}Building for aarch64-apple-darwin...${NC}" +cargo build --release --target aarch64-apple-darwin --features gpu-metal + +# Create universal binary +echo +echo -e "${YELLOW}Creating universal binary...${NC}" +UNIVERSAL_PATH="${OUTPUT_DIR}/voxtype-${VERSION}-macos-universal" +lipo -create \ + target/x86_64-apple-darwin/release/voxtype \ + target/aarch64-apple-darwin/release/voxtype \ + -output "$UNIVERSAL_PATH" + +# Verify the binary +echo +echo "Verifying universal binary..." +lipo -info "$UNIVERSAL_PATH" + +# Make executable +chmod +x "$UNIVERSAL_PATH" + +# Get file size +SIZE=$(du -h "$UNIVERSAL_PATH" | cut -f1) + +echo +echo -e "${GREEN}Build complete!${NC}" +echo " Binary: $UNIVERSAL_PATH" +echo " Size: $SIZE" +echo " Architectures: $(lipo -archs "$UNIVERSAL_PATH")" + +# Verify version +echo +echo "Verifying version..." +"$UNIVERSAL_PATH" --version + +echo +echo "Next steps:" +echo " 1. Sign the binary: ./scripts/sign-macos.sh $UNIVERSAL_PATH" +echo " 2. Notarize: ./scripts/notarize-macos.sh $UNIVERSAL_PATH" +echo " 3. Create DMG: ./scripts/build-macos-dmg.sh ${VERSION}" diff --git a/scripts/notarize-macos.sh b/scripts/notarize-macos.sh new file mode 100755 index 00000000..e1b1b1f8 --- /dev/null +++ b/scripts/notarize-macos.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# +# Notarize a macOS binary with Apple +# +# Requires: +# - Binary signed with Developer ID certificate +# - App-specific password for notarization +# +# Environment variables (required): +# APPLE_ID - Apple Developer account email +# APPLE_ID_PASSWORD - App-specific password (NOT your account password) +# APPLE_TEAM_ID - Team ID from Apple Developer account +# +# Usage: +# ./scripts/notarize-macos.sh releases/0.5.0/voxtype-0.5.0-macos-universal + +set -euo pipefail + +BINARY="${1:-}" + +if [[ -z "$BINARY" || ! -f "$BINARY" ]]; then + echo "Usage: $0 BINARY_PATH" + echo "Example: $0 releases/0.5.0/voxtype-0.5.0-macos-universal" + exit 1 +fi + +# Check required environment variables +if [[ -z "${APPLE_ID:-}" ]]; then + echo "Error: APPLE_ID environment variable not set" + echo "Set to your Apple Developer account email" + exit 1 +fi + +if [[ -z "${APPLE_ID_PASSWORD:-}" ]]; then + echo "Error: APPLE_ID_PASSWORD environment variable not set" + echo "Create an app-specific password at https://appleid.apple.com/account/manage" + exit 1 +fi + +if [[ -z "${APPLE_TEAM_ID:-}" ]]; then + echo "Error: APPLE_TEAM_ID environment variable not set" + echo "Find at https://developer.apple.com/account/#/membership" + exit 1 +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}Notarizing macOS binary...${NC}" +echo "Binary: $BINARY" +echo + +# Create ZIP for notarization (Apple requires a container format) +ZIP_PATH="${BINARY}.zip" +echo -e "${YELLOW}Creating ZIP for submission...${NC}" +ditto -c -k "$BINARY" "$ZIP_PATH" +echo "Created: $ZIP_PATH" +echo + +# Submit for notarization +echo -e "${YELLOW}Submitting to Apple for notarization...${NC}" +echo "This may take several minutes..." +echo + +xcrun notarytool submit "$ZIP_PATH" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + +# Clean up ZIP +rm -f "$ZIP_PATH" + +# Staple the notarization ticket +echo +echo -e "${YELLOW}Stapling notarization ticket...${NC}" +xcrun stapler staple "$BINARY" + +# Verify +echo +echo -e "${YELLOW}Verifying notarization...${NC}" +spctl -a -v "$BINARY" + +echo +echo -e "${GREEN}Notarization complete!${NC}" +echo +echo "The binary is now notarized and can be distributed." +echo "Users will not see Gatekeeper warnings when running it." +echo +echo "Next steps:" +echo " 1. Create DMG: ./scripts/build-macos-dmg.sh VERSION" diff --git a/scripts/sign-macos.sh b/scripts/sign-macos.sh new file mode 100755 index 00000000..4a69a568 --- /dev/null +++ b/scripts/sign-macos.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# +# Sign a macOS binary for distribution +# +# Requires: +# - Apple Developer ID Application certificate +# - Certificate installed in keychain +# +# Environment variables: +# CODESIGN_IDENTITY - Developer ID (default: auto-detect from keychain) +# +# Usage: +# ./scripts/sign-macos.sh releases/0.5.0/voxtype-0.5.0-macos-universal + +set -euo pipefail + +BINARY="${1:-}" + +if [[ -z "$BINARY" || ! -f "$BINARY" ]]; then + echo "Usage: $0 BINARY_PATH" + echo "Example: $0 releases/0.5.0/voxtype-0.5.0-macos-universal" + exit 1 +fi + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo -e "${GREEN}Signing macOS binary...${NC}" +echo "Binary: $BINARY" +echo + +# Find signing identity +if [[ -n "${CODESIGN_IDENTITY:-}" ]]; then + IDENTITY="$CODESIGN_IDENTITY" +else + # Try to find a Developer ID certificate + IDENTITY=$(security find-identity -v -p codesigning | grep "Developer ID Application" | head -1 | sed 's/.*"\(.*\)".*/\1/' || true) + + if [[ -z "$IDENTITY" ]]; then + echo -e "${RED}Error: No Developer ID Application certificate found in keychain${NC}" + echo + echo "To sign binaries for distribution outside the Mac App Store, you need:" + echo " 1. Apple Developer Program membership" + echo " 2. Developer ID Application certificate" + echo + echo "Install certificate: Xcode > Preferences > Accounts > Manage Certificates" + echo "Or set CODESIGN_IDENTITY environment variable" + exit 1 + fi +fi + +echo "Using identity: $IDENTITY" +echo + +# Sign the binary +echo -e "${YELLOW}Signing...${NC}" +codesign --deep --force --verify --verbose \ + --sign "$IDENTITY" \ + --timestamp \ + --options runtime \ + "$BINARY" + +echo +echo -e "${YELLOW}Verifying signature...${NC}" +codesign -dv --verbose=4 "$BINARY" 2>&1 | head -20 + +echo +echo -e "${GREEN}Signature verification:${NC}" +codesign --verify --strict --verbose=2 "$BINARY" + +echo +echo -e "${GREEN}Binary signed successfully!${NC}" +echo +echo "Next steps:" +echo " 1. Notarize: ./scripts/notarize-macos.sh $BINARY" diff --git a/src/cli.rs b/src/cli.rs index fc5ba6a4..65148347 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -103,6 +103,10 @@ pub enum Commands { /// Run as daemon (default if no command specified) Daemon, + /// Run menu bar helper (macOS) + #[cfg(target_os = "macos")] + Menubar, + /// Transcribe an audio file (WAV, 16kHz, mono) Transcribe { /// Path to audio file @@ -181,6 +185,9 @@ pub enum Commands { #[command(subcommand)] action: RecordAction, }, + + /// Check for updates + CheckUpdate, } /// Output mode override for record commands @@ -345,7 +352,11 @@ pub enum SetupAction { /// Check system configuration and dependencies Check, - /// Install voxtype as a systemd user service + /// Interactive macOS setup wizard + #[cfg(target_os = "macos")] + Macos, + + /// Install voxtype as a systemd user service (Linux) Systemd { /// Uninstall the service instead of installing #[arg(long)] @@ -356,6 +367,38 @@ pub enum SetupAction { status: bool, }, + /// Install voxtype as a LaunchAgent (macOS) + #[cfg(target_os = "macos")] + Launchd { + /// Uninstall the service instead of installing + #[arg(long)] + uninstall: bool, + + /// Show service status + #[arg(long)] + status: bool, + }, + + /// Set up Hammerspoon hotkey integration (macOS) + #[cfg(target_os = "macos")] + Hammerspoon { + /// Install Hammerspoon config (copy to ~/.hammerspoon/) + #[arg(long)] + install: bool, + + /// Show the Hammerspoon configuration snippet + #[arg(long)] + show: bool, + + /// Hotkey to configure (default: rightalt) + #[arg(long, default_value = "rightalt")] + hotkey: String, + + /// Use toggle mode instead of push-to-talk + #[arg(long)] + toggle: bool, + }, + /// Show Waybar configuration snippets Waybar { /// Output only the JSON config (for scripting) diff --git a/src/config.rs b/src/config.rs index 84d4bfc3..cdae097d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -417,7 +417,11 @@ pub struct StatusConfig { } fn default_icon_theme() -> String { - "emoji".to_string() + if cfg!(target_os = "macos") { + "nerd-font".to_string() + } else { + "emoji".to_string() + } } impl Default for StatusConfig { diff --git a/src/cpu.rs b/src/cpu.rs index c061af29..247c6f3b 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -12,11 +12,12 @@ use std::sync::atomic::{AtomicBool, Ordering}; static SIGILL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); -/// Constructor function that runs before main() via .init_array +/// Constructor function that runs before main() via platform-specific init section /// This ensures the SIGILL handler is installed before any library /// initialization code that might use unsupported instructions. #[used] -#[link_section = ".init_array"] +#[cfg_attr(target_os = "linux", link_section = ".init_array")] +#[cfg_attr(target_os = "macos", link_section = "__DATA,__mod_init_func")] static INIT_SIGILL_HANDLER: extern "C" fn() = { extern "C" fn init() { install_sigill_handler(); diff --git a/src/daemon.rs b/src/daemon.rs index 2eb40877..95f5e394 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -7,8 +7,12 @@ use crate::audio::feedback::{AudioFeedback, SoundEvent}; use crate::audio::{self, AudioCapture}; use crate::config::{ActivationMode, Config, FileMode, OutputMode}; use crate::error::Result; +#[cfg(target_os = "linux")] use crate::hotkey::{self, HotkeyEvent}; +#[cfg(target_os = "macos")] +use crate::hotkey_macos::{self as hotkey, HotkeyEvent}; use crate::model_manager::ModelManager; +use crate::notification; use crate::output; use crate::output::post_process::PostProcessor; use crate::state::State; @@ -16,10 +20,12 @@ use crate::text::TextProcessor; use crate::transcribe::Transcriber; use pidlock::Pidlock; use std::path::PathBuf; -use std::process::Stdio; +#[cfg(unix)] +use nix::sys::signal::{kill, Signal}; +#[cfg(unix)] +use nix::unistd::Pid; use std::sync::Arc; use std::time::Duration; -use tokio::process::Command; use tokio::signal::unix::{signal, SignalKind}; /// Send a desktop notification with optional engine icon @@ -30,17 +36,7 @@ async fn send_notification(title: &str, body: &str, show_engine_icon: bool, engi title.to_string() }; - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--expire-time=2000", - &title, - body, - ]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; + notification::send(&title, body).await; } /// Write state to file for external integrations (e.g., Waybar) @@ -91,6 +87,33 @@ fn write_pid_file() -> Option { Some(pid_path) } +/// Check if a PID is still running +#[cfg(unix)] +fn is_pid_running(pid: i32) -> bool { + // kill with signal 0 checks if process exists without sending a signal + kill(Pid::from_raw(pid), Signal::SIGCONT).is_ok() + || kill(Pid::from_raw(pid), None).is_ok() +} + +/// Check if lockfile is stale (PID no longer running) and remove it if so +#[cfg(unix)] +fn cleanup_stale_lockfile(lock_path: &std::path::Path) -> bool { + if let Ok(contents) = std::fs::read_to_string(lock_path) { + if let Ok(pid) = contents.trim().parse::() { + if pid > 0 && !is_pid_running(pid) { + tracing::info!( + "Removing stale lockfile (PID {} is no longer running)", + pid + ); + if std::fs::remove_file(lock_path).is_ok() { + return true; + } + } + } + } + false +} + /// Remove PID file on shutdown fn cleanup_pid_file(path: &PathBuf) { if path.exists() { @@ -530,18 +553,18 @@ impl Daemon { tracing::error!("No transcriber available"); self.play_feedback(SoundEvent::Error); self.reset_to_idle(state).await; - return false; + false } } Err(e) => { tracing::warn!("Recording error: {}", e); self.reset_to_idle(state).await; - return false; + false } } } else { self.reset_to_idle(state).await; - return false; + false } } @@ -778,15 +801,32 @@ impl Daemon { Ok(_) => { tracing::debug!("Acquired PID lock at {:?}", lock_path); } - Err(e) => { - tracing::error!( - "Failed to acquire lock: another voxtype instance is already running" - ); - return Err(crate::error::VoxtypeError::Config(format!( - "Another voxtype instance is already running (lock error: {:?})", - e - )) - .into()); + Err(_) => { + // Check if the lock is stale (previous daemon crashed) + #[cfg(unix)] + if cleanup_stale_lockfile(&lock_path) { + // Try again after removing stale lock + pidlock = Pidlock::new(&lock_path_str); + if let Err(e) = pidlock.acquire() { + tracing::error!("Failed to acquire lock after stale cleanup: {:?}", e); + return Err(crate::error::VoxtypeError::Config( + format!("Another voxtype instance is already running (lock error: {:?})", e) + ).into()); + } + tracing::debug!("Acquired PID lock at {:?} (after stale cleanup)", lock_path); + } else { + tracing::error!("Failed to acquire lock: another voxtype instance is already running"); + return Err(crate::error::VoxtypeError::Config( + "Another voxtype instance is already running".to_string() + ).into()); + } + #[cfg(not(unix))] + { + tracing::error!("Failed to acquire lock: another voxtype instance is already running"); + return Err(crate::error::VoxtypeError::Config( + "Another voxtype instance is already running".to_string() + ).into()); + } } } @@ -797,8 +837,9 @@ impl Daemon { tracing::info!("State file: {:?}", path); } - // Initialize hotkey listener (if enabled) - let mut hotkey_listener = if self.config.hotkey.enabled { + // Initialize hotkey listener (Linux: evdev, macOS: rdev) + #[cfg(target_os = "linux")] + let mut hotkey_listener: Option> = if self.config.hotkey.enabled { tracing::info!("Hotkey: {}", self.config.hotkey.key); let secondary_model = self.config.whisper.secondary_model.clone(); Some(hotkey::create_listener(&self.config.hotkey, secondary_model)?) @@ -809,6 +850,30 @@ impl Daemon { None }; + #[cfg(target_os = "macos")] + let mut hotkey_listener: Option> = if self.config.hotkey.enabled { + tracing::info!("Hotkey: {}", self.config.hotkey.key); + match hotkey::create_listener(&self.config.hotkey) { + Ok(listener) => Some(listener), + Err(e) => { + tracing::warn!("Failed to create hotkey listener: {}. Use 'voxtype record' commands instead.", e); + None + } + } + } else { + tracing::info!( + "Built-in hotkey disabled, use 'voxtype record' commands or compositor keybindings" + ); + None + }; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let hotkey_listener: Option<()> = { + if self.config.hotkey.enabled { + tracing::warn!("Built-in hotkey not supported on this platform, use 'voxtype record' commands"); + } + None + }; + // Log default output chain (chain is created dynamically per-transcription to support overrides) let default_chain = output::create_output_chain(&self.config.output); tracing::debug!( @@ -857,11 +922,20 @@ impl Daemon { self.model_manager = Some(model_manager); // Start hotkey listener (if enabled) + #[cfg(any(target_os = "linux", target_os = "macos"))] let mut hotkey_rx = if let Some(ref mut listener) = hotkey_listener { - Some(listener.start().await?) + match listener.start() { + Ok(rx) => Some(rx), + Err(e) => { + tracing::warn!("Failed to start hotkey listener: {}. Use 'voxtype record' commands instead.", e); + None + } + } } else { None }; + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let mut hotkey_rx: Option> = None; // Current state let mut state = State::Idle; @@ -1426,10 +1500,13 @@ impl Daemon { } } - // Cleanup + // Cleanup hotkey listener + #[cfg(any(target_os = "linux", target_os = "macos"))] if let Some(mut listener) = hotkey_listener { - listener.stop().await?; + let _ = listener.stop(); // Best effort cleanup } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = hotkey_listener; // Silence unused variable warning // Abort any pending transcription task if let Some(task) = self.transcription_task.take() { @@ -1692,4 +1769,37 @@ mod tests { ); }); } + + #[test] + fn test_stale_lockfile_cleanup() { + with_test_runtime_dir(|dir| { + let lock_path = dir.join("voxtype.lock"); + + // Write a stale lockfile with a PID that doesn't exist + // PID 99999999 is very unlikely to exist + std::fs::write(&lock_path, "99999999").expect("Failed to write stale lockfile"); + assert!(lock_path.exists(), "Stale lockfile should exist"); + + // cleanup_stale_lockfile should detect and remove it + let cleaned = cleanup_stale_lockfile(&lock_path); + assert!(cleaned, "Stale lockfile should be cleaned up"); + assert!(!lock_path.exists(), "Stale lockfile should be removed"); + }); + } + + #[test] + fn test_stale_lockfile_not_cleaned_if_pid_running() { + with_test_runtime_dir(|dir| { + let lock_path = dir.join("voxtype.lock"); + + // Write a lockfile with our own PID (which is running) + let our_pid = std::process::id(); + std::fs::write(&lock_path, our_pid.to_string()).expect("Failed to write lockfile"); + + // cleanup_stale_lockfile should NOT remove it (PID is running) + let cleaned = cleanup_stale_lockfile(&lock_path); + assert!(!cleaned, "Lockfile with running PID should not be cleaned"); + assert!(lock_path.exists(), "Lockfile should still exist"); + }); + } } diff --git a/src/error.rs b/src/error.rs index 08910492..fb500fe6 100644 --- a/src/error.rs +++ b/src/error.rs @@ -91,6 +91,9 @@ pub enum TranscribeError { #[error("Remote server error: {0}")] RemoteError(String), + + #[error("{0}")] + LicenseRequired(String), } /// Errors related to text output diff --git a/src/hotkey/evdev_listener.rs b/src/hotkey/evdev_listener.rs index aa090513..1037d03b 100644 --- a/src/hotkey/evdev_listener.rs +++ b/src/hotkey/evdev_listener.rs @@ -80,9 +80,8 @@ impl EvdevListener { } } -#[async_trait::async_trait] impl HotkeyListener for EvdevListener { - async fn start(&mut self) -> Result, HotkeyError> { + fn start(&mut self) -> Result, HotkeyError> { let (tx, rx) = mpsc::channel(32); let (stop_tx, stop_rx) = oneshot::channel(); self.stop_signal = Some(stop_tx); @@ -111,7 +110,7 @@ impl HotkeyListener for EvdevListener { Ok(rx) } - async fn stop(&mut self) -> Result<(), HotkeyError> { + fn stop(&mut self) -> Result<(), HotkeyError> { if let Some(stop) = self.stop_signal.take() { let _ = stop.send(()); } diff --git a/src/hotkey/mod.rs b/src/hotkey/mod.rs index 0420c40d..6b6d2140 100644 --- a/src/hotkey/mod.rs +++ b/src/hotkey/mod.rs @@ -27,14 +27,13 @@ pub enum HotkeyEvent { } /// Trait for hotkey detection implementations -#[async_trait::async_trait] -pub trait HotkeyListener: Send + Sync { +pub trait HotkeyListener: Send { /// Start listening for hotkey events /// Returns a channel receiver for events - async fn start(&mut self) -> Result, HotkeyError>; + fn start(&mut self) -> Result, HotkeyError>; /// Stop listening and clean up - async fn stop(&mut self) -> Result<(), HotkeyError>; + fn stop(&mut self) -> Result<(), HotkeyError>; } /// Factory function to create the appropriate hotkey listener diff --git a/src/hotkey_macos.rs b/src/hotkey_macos.rs new file mode 100644 index 00000000..3a82c1f6 --- /dev/null +++ b/src/hotkey_macos.rs @@ -0,0 +1,249 @@ +//! macOS global hotkey support using rdev +//! +//! Provides global keyboard event capture on macOS using the rdev crate. +//! Requires Accessibility permission to be granted to the terminal/app. +//! +//! Fallback: If rdev doesn't work (permissions not granted), users can use +//! Hammerspoon or Karabiner-Elements to trigger `voxtype record toggle`. + +use crate::config::HotkeyConfig; +use crate::error::{HotkeyError, Result}; +use rdev::{listen, Event, EventType, Key}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use std::sync::Mutex; +use tokio::sync::mpsc; + +/// Hotkey events that can be sent from the listener +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HotkeyEvent { + Pressed, + Released, + Cancel, +} + +/// Hotkey listener trait for macOS +pub trait HotkeyListener: Send { + /// Start listening for hotkey events + fn start(&mut self) -> Result>; + + /// Stop listening + fn stop(&mut self) -> Result<()>; +} + +/// rdev-based hotkey listener for macOS +pub struct RdevHotkeyListener { + target_key: Key, + cancel_key: Option, + running: Arc, + thread_handle: Option>, +} + +impl RdevHotkeyListener { + /// Create a new rdev hotkey listener + pub fn new(config: &HotkeyConfig) -> Result { + let target_key = parse_key_name(&config.key).ok_or_else(|| { + HotkeyError::UnknownKey(config.key.clone()) + })?; + + let cancel_key = config + .cancel_key + .as_ref() + .and_then(|k| parse_key_name(k)); + + Ok(Self { + target_key, + cancel_key, + running: Arc::new(AtomicBool::new(false)), + thread_handle: None, + }) + } +} + +impl HotkeyListener for RdevHotkeyListener { + fn start(&mut self) -> Result> { + let (tx, rx) = mpsc::channel(32); + let target_key = self.target_key; + let cancel_key = self.cancel_key; + let running = self.running.clone(); + running.store(true, Ordering::SeqCst); + + let thread_handle = std::thread::spawn(move || { + let tx_clone = tx.clone(); + let running_clone = running.clone(); + + // Debounce: track last event time to prevent duplicate events + let last_press = Arc::new(Mutex::new(Instant::now() - Duration::from_secs(10))); + let last_release = Arc::new(Mutex::new(Instant::now() - Duration::from_secs(10))); + let debounce_ms = 100; // Minimum ms between same event type + + let last_press_clone = last_press.clone(); + let last_release_clone = last_release.clone(); + + let callback = move |event: Event| { + if !running_clone.load(Ordering::SeqCst) { + return; + } + + match event.event_type { + EventType::KeyPress(key) => { + if key == target_key { + let mut last = last_press_clone.lock().unwrap(); + if last.elapsed() > Duration::from_millis(debounce_ms) { + *last = Instant::now(); + let _ = tx_clone.blocking_send(HotkeyEvent::Pressed); + } + } else if Some(key) == cancel_key { + let _ = tx_clone.blocking_send(HotkeyEvent::Cancel); + } + } + EventType::KeyRelease(key) => { + if key == target_key { + let mut last = last_release_clone.lock().unwrap(); + if last.elapsed() > Duration::from_millis(debounce_ms) { + *last = Instant::now(); + let _ = tx_clone.blocking_send(HotkeyEvent::Released); + } + } + } + _ => {} + } + }; + + // This blocks until an error occurs or the process is terminated + if let Err(e) = listen(callback) { + tracing::error!("rdev listen error: {:?}", e); + tracing::warn!( + "Global hotkey capture failed. Grant Accessibility permission in \ + System Settings > Privacy & Security > Accessibility, \ + or use Hammerspoon for hotkey support." + ); + } + }); + + self.thread_handle = Some(thread_handle); + Ok(rx) + } + + fn stop(&mut self) -> Result<()> { + self.running.store(false, Ordering::SeqCst); + // Note: rdev's listen() doesn't have a clean way to stop from another thread + // The thread will stop when the process exits or on the next event + Ok(()) + } +} + +/// Parse a key name string to rdev Key +fn parse_key_name(name: &str) -> Option { + match name.to_uppercase().as_str() { + // Function keys + "F1" => Some(Key::F1), + "F2" => Some(Key::F2), + "F3" => Some(Key::F3), + "F4" => Some(Key::F4), + "F5" => Some(Key::F5), + "F6" => Some(Key::F6), + "F7" => Some(Key::F7), + "F8" => Some(Key::F8), + "F9" => Some(Key::F9), + "F10" => Some(Key::F10), + "F11" => Some(Key::F11), + "F12" => Some(Key::F12), + + // Modifier keys + "LEFTALT" | "LEFTOPT" | "LEFTOPTION" | "ALT" | "OPTION" => Some(Key::Alt), + "RIGHTALT" | "RIGHTOPT" | "RIGHTOPTION" => Some(Key::AltGr), + "LEFTCTRL" | "LEFTCONTROL" | "CTRL" | "CONTROL" => Some(Key::ControlLeft), + "RIGHTCTRL" | "RIGHTCONTROL" => Some(Key::ControlRight), + "LEFTSHIFT" | "SHIFT" => Some(Key::ShiftLeft), + "RIGHTSHIFT" => Some(Key::ShiftRight), + "LEFTMETA" | "LEFTCMD" | "LEFTCOMMAND" | "CMD" | "COMMAND" | "META" => Some(Key::MetaLeft), + "RIGHTMETA" | "RIGHTCMD" | "RIGHTCOMMAND" => Some(Key::MetaRight), + + // Special keys + "ESCAPE" | "ESC" => Some(Key::Escape), + "SPACE" => Some(Key::Space), + "TAB" => Some(Key::Tab), + "CAPSLOCK" => Some(Key::CapsLock), + "BACKSPACE" => Some(Key::Backspace), + "ENTER" | "RETURN" => Some(Key::Return), + + // Navigation + "UP" | "UPARROW" => Some(Key::UpArrow), + "DOWN" | "DOWNARROW" => Some(Key::DownArrow), + "LEFT" | "LEFTARROW" => Some(Key::LeftArrow), + "RIGHT" | "RIGHTARROW" => Some(Key::RightArrow), + "HOME" => Some(Key::Home), + "END" => Some(Key::End), + "PAGEUP" => Some(Key::PageUp), + "PAGEDOWN" => Some(Key::PageDown), + + // Other + "DELETE" => Some(Key::Delete), + "INSERT" => Some(Key::Insert), + "PAUSE" => Some(Key::Pause), + "SCROLLLOCK" => Some(Key::ScrollLock), + "PRINTSCREEN" => Some(Key::PrintScreen), + + // Letters (for completeness, though unusual for hotkeys) + "A" => Some(Key::KeyA), + "B" => Some(Key::KeyB), + "C" => Some(Key::KeyC), + "D" => Some(Key::KeyD), + "E" => Some(Key::KeyE), + "F" => Some(Key::KeyF), + "G" => Some(Key::KeyG), + "H" => Some(Key::KeyH), + "I" => Some(Key::KeyI), + "J" => Some(Key::KeyJ), + "K" => Some(Key::KeyK), + "L" => Some(Key::KeyL), + "M" => Some(Key::KeyM), + "N" => Some(Key::KeyN), + "O" => Some(Key::KeyO), + "P" => Some(Key::KeyP), + "Q" => Some(Key::KeyQ), + "R" => Some(Key::KeyR), + "S" => Some(Key::KeyS), + "T" => Some(Key::KeyT), + "U" => Some(Key::KeyU), + "V" => Some(Key::KeyV), + "W" => Some(Key::KeyW), + "X" => Some(Key::KeyX), + "Y" => Some(Key::KeyY), + "Z" => Some(Key::KeyZ), + + _ => None, + } +} + +/// Create a hotkey listener for macOS +pub fn create_listener(config: &HotkeyConfig) -> Result> { + Ok(Box::new(RdevHotkeyListener::new(config)?)) +} + +/// Check if rdev hotkey capture is likely to work +/// (Accessibility permission is required) +pub fn check_accessibility_permission() -> bool { + // On macOS, we can try to create an event tap to check permissions + // rdev will fail at runtime if permissions aren't granted + // For now, return true and let it fail gracefully with a helpful message + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_key_name() { + assert_eq!(parse_key_name("F1"), Some(Key::F1)); + assert_eq!(parse_key_name("f1"), Some(Key::F1)); + assert_eq!(parse_key_name("RIGHTALT"), Some(Key::AltGr)); + assert_eq!(parse_key_name("rightoption"), Some(Key::AltGr)); + assert_eq!(parse_key_name("CMD"), Some(Key::MetaLeft)); + assert_eq!(parse_key_name("SCROLLLOCK"), Some(Key::ScrollLock)); + assert_eq!(parse_key_name("UNKNOWN"), None); + } +} diff --git a/src/lib.rs b/src/lib.rs index 474f31f8..ba7fd07b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -74,8 +74,14 @@ pub mod config; pub mod cpu; pub mod daemon; pub mod error; +#[cfg(target_os = "linux")] pub mod hotkey; +#[cfg(target_os = "macos")] +pub mod hotkey_macos; pub mod model_manager; +#[cfg(target_os = "macos")] +pub mod menubar; +pub mod notification; pub mod output; pub mod setup; pub mod state; diff --git a/src/main.rs b/src/main.rs index f4ba7b4c..1bc9c24a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,9 +6,10 @@ use clap::Parser; use std::path::PathBuf; -use std::process::Command; use tracing_subscriber::EnvFilter; use voxtype::{config, cpu, daemon, setup, transcribe, Cli, Commands, RecordAction, SetupAction}; +#[cfg(target_os = "macos")] +use voxtype::menubar; /// Parse a comma-separated list of driver names into OutputDriver vec fn parse_driver_order(s: &str) -> Result, String> { @@ -85,14 +86,10 @@ async fn main() -> anyhow::Result<()> { default_model ); // Send desktop notification - let _ = Command::new("notify-send") - .args([ - "--app-name=Voxtype", - "--expire-time=5000", - "Voxtype: Invalid Model", - &format!("Unknown model '{}', using '{}'", model, default_model), - ]) - .spawn(); + voxtype::notification::send_sync( + "Voxtype: Invalid Model", + &format!("Unknown model '{}', using '{}'", model, default_model), + ); } } if let Some(engine) = cli.engine { @@ -142,6 +139,13 @@ async fn main() -> anyhow::Result<()> { let mut daemon = daemon::Daemon::new(config, config_path); daemon.run().await?; } + #[cfg(target_os = "macos")] + Commands::Menubar => { + let state_file = config.resolve_state_file() + .ok_or_else(|| anyhow::anyhow!("state_file not configured"))?; + menubar::run(state_file); + // Note: menubar::run() never returns (runs macOS event loop) + } Commands::Transcribe { file } => { transcribe_file(&config, &file)?; @@ -193,6 +197,24 @@ async fn main() -> anyhow::Result<()> { setup::systemd::install().await?; } } + #[cfg(target_os = "macos")] + Some(SetupAction::Launchd { uninstall, status }) => { + if status { + setup::launchd::status().await?; + } else if uninstall { + setup::launchd::uninstall().await?; + } else { + setup::launchd::install().await?; + } + } + #[cfg(target_os = "macos")] + Some(SetupAction::Hammerspoon { install, show, hotkey, toggle }) => { + setup::hammerspoon::run(install, show, &hotkey, toggle).await?; + } + #[cfg(target_os = "macos")] + Some(SetupAction::Macos) => { + setup::macos::run().await?; + } Some(SetupAction::Waybar { json, css, @@ -290,6 +312,75 @@ async fn main() -> anyhow::Result<()> { Commands::Record { action } => { send_record_command(&config, action)?; } + + + Commands::CheckUpdate => { + check_for_updates().await?; + } + } + + Ok(()) +} + +/// Check for updates by comparing version with GitHub releases +async fn check_for_updates() -> anyhow::Result<()> { + let current = env!("CARGO_PKG_VERSION"); + println!("Voxtype Update Check\n"); + println!("====================\n"); + println!("Current version: {}", current); + println!("Checking for updates...\n"); + + // Fetch latest release from GitHub API (blocking call wrapped in spawn_blocking) + let result = tokio::task::spawn_blocking(|| { + ureq::get("https://api.github.com/repos/peteonrails/voxtype/releases/latest") + .set("User-Agent", "voxtype-update-checker") + .call() + }) + .await?; + + match result { + Ok(resp) => { + let release: serde_json::Value = resp.into_json()?; + if let Some(tag) = release["tag_name"].as_str() { + let latest = tag.trim_start_matches('v'); + + // Compare versions using semver + let current_ver = semver::Version::parse(current) + .unwrap_or_else(|_| semver::Version::new(0, 0, 0)); + let latest_ver = semver::Version::parse(latest) + .unwrap_or_else(|_| semver::Version::new(0, 0, 0)); + + if latest_ver > current_ver { + println!("\x1b[33m⚠ Update available: {} → {}\x1b[0m\n", current, latest); + println!("Download: https://github.com/peteonrails/voxtype/releases/tag/{}", tag); + println!("Website: https://voxtype.io/download"); + + // Show release notes excerpt if available + if let Some(body) = release["body"].as_str() { + let summary: String = body.lines().take(5).collect::>().join("\n"); + if !summary.is_empty() { + println!("\nRelease notes:"); + println!("{}", summary); + if body.lines().count() > 5 { + println!("..."); + } + } + } + } else { + println!("\x1b[32m✓ You're on the latest version ({}).\x1b[0m", current); + } + } else { + println!("Could not parse latest version from GitHub."); + } + } + Err(ureq::Error::Status(code, _)) => { + eprintln!("GitHub API returned status: {}", code); + eprintln!("Try again later or check manually: https://github.com/peteonrails/voxtype/releases"); + } + Err(e) => { + eprintln!("Failed to check for updates: {}", e); + eprintln!("Check manually: https://github.com/peteonrails/voxtype/releases"); + } } Ok(()) @@ -553,13 +644,14 @@ fn is_daemon_running() -> bool { Err(_) => return false, // No PID file = not running }; - let pid: u32 = match pid_str.trim().parse() { + let pid: i32 = match pid_str.trim().parse() { Ok(p) => p, Err(_) => return false, // Invalid PID = not running }; - // Check if process exists by testing /proc/{pid} - std::path::Path::new(&format!("/proc/{}", pid)).exists() + // Check if process exists using kill(pid, 0) - works on both Linux and macOS + // Signal 0 doesn't send a signal, just checks if process exists and we have permission + unsafe { libc::kill(pid, 0) == 0 } } /// Run the status command - show current daemon state diff --git a/src/menubar.rs b/src/menubar.rs new file mode 100644 index 00000000..f3d3d456 --- /dev/null +++ b/src/menubar.rs @@ -0,0 +1,196 @@ +//! macOS menu bar integration +//! +//! Provides a system tray icon showing voxtype status with a context menu +//! for controlling recording. + +use crate::config::Config; +use pidlock::Pidlock; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tao::event_loop::{ControlFlow, EventLoopBuilder}; +use tray_icon::{ + menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, + TrayIconBuilder, +}; + +/// Current voxtype state +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VoxtypeState { + Idle, + Recording, + Transcribing, + Stopped, +} + +impl VoxtypeState { + fn from_str(s: &str) -> Self { + match s.trim().to_lowercase().as_str() { + "idle" => VoxtypeState::Idle, + "recording" => VoxtypeState::Recording, + "transcribing" => VoxtypeState::Transcribing, + _ => VoxtypeState::Stopped, + } + } + + fn icon(&self) -> &'static str { + match self { + VoxtypeState::Idle => "🎙", + VoxtypeState::Recording => "🔴", + VoxtypeState::Transcribing => "⏳", + VoxtypeState::Stopped => "⬛", + } + } + + fn status_text(&self) -> &'static str { + match self { + VoxtypeState::Idle => "Status: Ready", + VoxtypeState::Recording => "Status: Recording...", + VoxtypeState::Transcribing => "Status: Transcribing...", + VoxtypeState::Stopped => "Status: Daemon not running", + } + } +} + +/// Menu item IDs +const MENU_TOGGLE: &str = "toggle"; +const MENU_CANCEL: &str = "cancel"; +const MENU_QUIT: &str = "quit"; + +/// Read state from file +fn read_state_from_file(path: &PathBuf) -> VoxtypeState { + std::fs::read_to_string(path) + .map(|s| VoxtypeState::from_str(&s)) + .unwrap_or(VoxtypeState::Stopped) +} + +/// Execute voxtype command +fn voxtype_cmd(cmd: &str) { + // Use the full path if available, otherwise hope it's in PATH + let voxtype_path = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("voxtype"))) + .filter(|p| p.exists()) + .unwrap_or_else(|| PathBuf::from("voxtype")); + + let _ = std::process::Command::new(voxtype_path) + .args(["record", cmd]) + .spawn(); +} + +/// Run the menu bar application +/// This should be called from the main thread +/// Note: This function never returns (runs the macOS event loop) +pub fn run(state_file: PathBuf) -> ! { + println!("Starting Voxtype menu bar..."); + println!("State file: {}", state_file.display()); + + // Single instance check + let lock_path = Config::runtime_dir().join("menubar.lock"); + let lock_path_str = lock_path.to_string_lossy().to_string(); + let mut pidlock = Pidlock::new(&lock_path_str); + + match pidlock.acquire() { + Ok(_) => { + println!("Acquired menu bar lock"); + } + Err(_) => { + eprintln!("Error: Another voxtype menubar instance is already running."); + std::process::exit(1); + } + } + + // Check if state file exists (daemon should be running) + if !state_file.exists() { + println!("\nWarning: State file not found. Is the voxtype daemon running?"); + println!("Start it with: voxtype daemon\n"); + } + + // Create menu items + let toggle_item = MenuItem::with_id(MENU_TOGGLE, "Toggle Recording", true, None); + let cancel_item = MenuItem::with_id(MENU_CANCEL, "Cancel Recording", true, None); + let status_item = MenuItem::new("Status: Checking...", false, None); + let quit_item = MenuItem::with_id(MENU_QUIT, "Quit Menu Bar", true, None); + + // Create menu + let menu = Menu::new(); + menu.append(&toggle_item).expect("Failed to append toggle item"); + menu.append(&cancel_item).expect("Failed to append cancel item"); + menu.append(&PredefinedMenuItem::separator()).expect("Failed to append separator"); + menu.append(&status_item).expect("Failed to append status item"); + menu.append(&PredefinedMenuItem::separator()).expect("Failed to append separator"); + menu.append(&quit_item).expect("Failed to append quit item"); + + // Get initial state + let initial_state = read_state_from_file(&state_file); + + // Create tray icon + let tray = TrayIconBuilder::new() + .with_tooltip("Voxtype") + .with_title(initial_state.icon()) + .with_menu(Box::new(menu)) + .build() + .expect("Failed to create tray icon"); + + // Update initial status + let _ = status_item.set_text(initial_state.status_text()); + + println!("Menu bar is running. Look for the icon in your menu bar."); + println!("Press Ctrl+C to stop.\n"); + + // Track state + let mut last_state = initial_state; + let mut last_update = Instant::now(); + let update_interval = Duration::from_millis(500); + let running = Arc::new(AtomicBool::new(true)); + + // Set up menu event receiver + let menu_channel = MenuEvent::receiver(); + + // Create event loop + let event_loop = EventLoopBuilder::new().build(); + + event_loop.run(move |_event, _, control_flow| { + // Set to poll mode so we can check state periodically + *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100)); + + // Check for menu events (non-blocking) + if let Ok(event) = menu_channel.try_recv() { + match event.id().0.as_str() { + MENU_TOGGLE => { + voxtype_cmd("toggle"); + } + MENU_CANCEL => { + voxtype_cmd("cancel"); + } + MENU_QUIT => { + running.store(false, Ordering::SeqCst); + *control_flow = ControlFlow::Exit; + } + _ => {} + } + } + + // Update state periodically + if last_update.elapsed() >= update_interval { + let new_state = read_state_from_file(&state_file); + + if new_state != last_state { + // Update icon + let _ = tray.set_title(Some(new_state.icon())); + + // Update status text + let _ = status_item.set_text(new_state.status_text()); + + last_state = new_state; + } + + last_update = Instant::now(); + } + + if !running.load(Ordering::SeqCst) { + *control_flow = ControlFlow::Exit; + } + }); +} diff --git a/src/notification.rs b/src/notification.rs new file mode 100644 index 00000000..b4657347 --- /dev/null +++ b/src/notification.rs @@ -0,0 +1,224 @@ +//! Platform-specific desktop notifications +//! +//! Provides a unified interface for sending desktop notifications on +//! different platforms: +//! - Linux: Uses notify-send (libnotify) +//! - macOS: Uses osascript (AppleScript) + +use std::process::Stdio; +use tokio::process::Command; + +/// Send a desktop notification with the given title and body. +/// +/// This function is async and non-blocking. Notification failures are +/// logged but don't propagate errors (notifications are best-effort). +pub async fn send(title: &str, body: &str) { + #[cfg(target_os = "linux")] + send_linux(title, body).await; + + #[cfg(target_os = "macos")] + send_macos(title, body).await; + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + tracing::debug!("Notifications not supported on this platform"); + let _ = (title, body); // Suppress unused warnings + } +} + +/// Send a notification on Linux using notify-send +#[cfg(target_os = "linux")] +async fn send_linux(title: &str, body: &str) { + let result = Command::new("notify-send") + .args([ + "--app-name=Voxtype", + "--expire-time=2000", + title, + body, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + if let Err(e) = result { + tracing::debug!("Failed to send notification: {}", e); + } +} + +/// Send a notification on macOS +/// Prefers terminal-notifier (supports custom icons) with fallback to osascript +#[cfg(target_os = "macos")] +async fn send_macos(title: &str, body: &str) { + // Try terminal-notifier first (supports custom icons) + if send_macos_terminal_notifier(title, body).await { + return; + } + + // Fallback to osascript + send_macos_osascript(title, body).await; +} + +/// Send notification via terminal-notifier (supports custom icons) +#[cfg(target_os = "macos")] +async fn send_macos_terminal_notifier(title: &str, body: &str) -> bool { + let mut args = vec![ + "-title".to_string(), + title.to_string(), + "-message".to_string(), + body.to_string(), + "-group".to_string(), + "voxtype".to_string(), + ]; + + // Add custom icon if available + if let Some(icon_path) = find_notification_icon() { + args.push("-appIcon".to_string()); + args.push(icon_path); + } + + let result = Command::new("terminal-notifier") + .args(&args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + match result { + Ok(status) => status.success(), + Err(_) => false, // terminal-notifier not available + } +} + +/// Send notification via osascript (fallback, no custom icon support) +#[cfg(target_os = "macos")] +async fn send_macos_osascript(title: &str, body: &str) { + let escaped_title = title.replace('"', "\\\""); + let escaped_body = body.replace('"', "\\\""); + + let script = format!( + r#"display notification "{}" with title "{}""#, + escaped_body, escaped_title + ); + + let result = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + + if let Err(e) = result { + tracing::debug!("Failed to send notification: {}", e); + } +} + +/// Find the notification icon path (returns file:// URL for terminal-notifier) +#[cfg(target_os = "macos")] +fn find_notification_icon() -> Option { + // Check common locations for the voxtype icon + let candidates = [ + // User-installed icon + dirs::data_dir().map(|d| d.join("voxtype/icon.png")), + // Config directory + dirs::config_dir().map(|d| d.join("voxtype/icon.png")), + // Homebrew installation + Some(std::path::PathBuf::from("/opt/homebrew/share/voxtype/icon.png")), + // System-wide + Some(std::path::PathBuf::from("/usr/local/share/voxtype/icon.png")), + ]; + + for candidate in candidates.into_iter().flatten() { + if candidate.exists() { + // Return as file:// URL to handle paths with spaces + let path_str = candidate.to_string_lossy(); + // URL-encode spaces and special characters + let encoded = path_str.replace(' ', "%20"); + return Some(format!("file://{}", encoded)); + } + } + + None +} + +/// Send a notification synchronously (blocking). +/// +/// Used in non-async contexts like early startup warnings. +pub fn send_sync(title: &str, body: &str) { + #[cfg(target_os = "linux")] + send_linux_sync(title, body); + + #[cfg(target_os = "macos")] + send_macos_sync(title, body); + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (title, body); // Suppress unused warnings + } +} + +/// Send a notification on Linux using notify-send (synchronous) +#[cfg(target_os = "linux")] +fn send_linux_sync(title: &str, body: &str) { + let _ = std::process::Command::new("notify-send") + .args([ + "--app-name=Voxtype", + "--expire-time=5000", + title, + body, + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); +} + +/// Send a notification on macOS (synchronous) +#[cfg(target_os = "macos")] +fn send_macos_sync(title: &str, body: &str) { + // Try terminal-notifier first + let mut args = vec!["-title", title, "-message", body, "-group", "voxtype"]; + + let icon_path = find_notification_icon(); + if let Some(ref path) = icon_path { + args.push("-appIcon"); + args.push(path); + } + + let result = std::process::Command::new("terminal-notifier") + .args(&args) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + if result.map(|s| s.success()).unwrap_or(false) { + return; + } + + // Fallback to osascript + let escaped_title = title.replace('"', "\\\""); + let escaped_body = body.replace('"', "\\\""); + + let script = format!( + r#"display notification "{}" with title "{}""#, + escaped_body, escaped_title + ); + + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_quote_escaping() { + // Test that quotes are properly escaped for AppleScript + let title = r#"Test "title""#; + let escaped = title.replace('"', "\\\""); + assert_eq!(escaped, r#"Test \"title\""#); + } +} diff --git a/src/output/cgevent.rs b/src/output/cgevent.rs new file mode 100644 index 00000000..f0d9819a --- /dev/null +++ b/src/output/cgevent.rs @@ -0,0 +1,271 @@ +//! macOS text output via CGEvent API +//! +//! Uses Core Graphics events to simulate keyboard input on macOS. +//! This is the native, preferred method for text injection on macOS. +//! +//! Requires Accessibility permissions: +//! System Settings > Privacy & Security > Accessibility +//! +//! Advantages over osascript: +//! - Native API, no subprocess spawning +//! - Direct Unicode support via CGEventKeyboardSetUnicodeString +//! - Lower latency and better reliability +//! - Proper keycode mapping with modifier support + +use super::TextOutput; +use crate::error::OutputError; +use core_foundation::base::TCFType; +use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGKeyCode}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use std::time::Duration; + +/// CGEvent-based text output for macOS +pub struct CGEventOutput { + /// Delay between keypresses in milliseconds + type_delay_ms: u32, + /// Delay before typing starts in milliseconds + pre_type_delay_ms: u32, + /// Whether to show a desktop notification + notify: bool, + /// Whether to send Enter key after output + auto_submit: bool, +} + +impl CGEventOutput { + /// Create a new CGEvent output + pub fn new( + type_delay_ms: u32, + pre_type_delay_ms: u32, + notify: bool, + auto_submit: bool, + ) -> Self { + Self { + type_delay_ms, + pre_type_delay_ms, + notify, + auto_submit, + } + } + + /// Check if Accessibility permissions are granted + fn check_accessibility_permission() -> bool { + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + unsafe { AXIsProcessTrusted() } + } + + /// Request Accessibility permissions (shows system dialog) + #[allow(dead_code)] + fn request_accessibility_permission() { + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrustedWithOptions(options: core_foundation::base::CFTypeRef) -> bool; + } + + use core_foundation::base::CFType; + use core_foundation::boolean::CFBoolean; + use core_foundation::dictionary::CFDictionary; + use core_foundation::string::CFString; + + let key = CFString::new("AXTrustedCheckOptionPrompt"); + let value = CFBoolean::true_value(); + let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); + + unsafe { + AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef() as _); + } + } + + /// Send a desktop notification using osascript + async fn send_notification(&self, text: &str) { + use std::process::Stdio; + use tokio::process::Command; + + let preview: String = text.chars().take(80).collect(); + let preview = if text.chars().count() > 80 { + format!("{}...", preview) + } else { + preview + }; + + let escaped = preview.replace('\\', "\\\\").replace('"', "\\\""); + let script = format!( + "display notification \"{}\" with title \"Voxtype\" subtitle \"Transcribed\"", + escaped + ); + + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } + + /// Type text using CGEvent (blocking, for use in spawn_blocking) + fn type_text_blocking( + text: &str, + type_delay_ms: u32, + auto_submit: bool, + ) -> Result<(), OutputError> { + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|_| OutputError::InjectionFailed("Failed to create CGEventSource".into()))?; + + let delay = Duration::from_millis(type_delay_ms as u64); + + // Type text using Unicode string injection for reliability + // This works with any keyboard layout and supports all characters + for chunk in text.chars().collect::>().chunks(20) { + Self::type_unicode_string(&source, chunk)?; + + if type_delay_ms > 0 && !chunk.is_empty() { + std::thread::sleep(delay); + } + } + + if auto_submit { + std::thread::sleep(Duration::from_millis(50)); + Self::press_key(&source, KEYCODE_RETURN, CGEventFlags::empty())?; + } + + Ok(()) + } + + /// Type a string using Unicode injection (handles any character) + fn type_unicode_string(source: &CGEventSource, chars: &[char]) -> Result<(), OutputError> { + if chars.is_empty() { + return Ok(()); + } + + // Convert to UTF-16 for CGEvent + let mut utf16_buf: Vec = Vec::with_capacity(chars.len() * 2); + for ch in chars { + let mut buf = [0u16; 2]; + let encoded = ch.encode_utf16(&mut buf); + utf16_buf.extend_from_slice(encoded); + } + + // Create key down event with Unicode string + let event = CGEvent::new_keyboard_event(source.clone(), 0, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create keyboard event".into()))?; + + event.set_string_from_utf16_unchecked(&utf16_buf); + event.post(CGEventTapLocation::HID); + + // Key up event + let event_up = CGEvent::new_keyboard_event(source.clone(), 0, false) + .map_err(|_| OutputError::InjectionFailed("Failed to create key up event".into()))?; + event_up.post(CGEventTapLocation::HID); + + Ok(()) + } + + /// Press a single key with optional modifiers + /// + /// Always explicitly sets flags to prevent Caps Lock or stuck modifiers + /// from interfering with text injection. + fn press_key( + source: &CGEventSource, + keycode: CGKeyCode, + flags: CGEventFlags, + ) -> Result<(), OutputError> { + let key_down = CGEvent::new_keyboard_event(source.clone(), keycode, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create key down event".into()))?; + + // Always set flags explicitly - use CGEventFlagNull when no modifiers needed + // This prevents Caps Lock or stuck modifier keys from causing random capitalization + key_down.set_flags(flags); + key_down.post(CGEventTapLocation::HID); + + let key_up = CGEvent::new_keyboard_event(source.clone(), keycode, false) + .map_err(|_| OutputError::InjectionFailed("Failed to create key up event".into()))?; + key_up.set_flags(flags); + key_up.post(CGEventTapLocation::HID); + + Ok(()) + } +} + +// macOS virtual key codes (from Carbon HIToolbox Events.h) +const KEYCODE_RETURN: CGKeyCode = 0x24; + +#[async_trait::async_trait] +impl TextOutput for CGEventOutput { + async fn output(&self, text: &str) -> Result<(), OutputError> { + if text.is_empty() { + return Ok(()); + } + + // Check permissions first + if !Self::check_accessibility_permission() { + return Err(OutputError::InjectionFailed( + "Accessibility permission required.\n\ + Grant access in: System Settings > Privacy & Security > Accessibility\n\ + Then restart voxtype.".into() + )); + } + + // Pre-typing delay + if self.pre_type_delay_ms > 0 { + tracing::debug!("cgevent: waiting {}ms before typing", self.pre_type_delay_ms); + tokio::time::sleep(Duration::from_millis(self.pre_type_delay_ms as u64)).await; + } + + tracing::debug!( + "cgevent: typing {} chars", + text.chars().count() + ); + + // CGEventSource is not Send, so do all CGEvent work in spawn_blocking + let text_owned = text.to_string(); + let type_delay_ms = self.type_delay_ms; + let auto_submit = self.auto_submit; + + tokio::task::spawn_blocking(move || { + Self::type_text_blocking(&text_owned, type_delay_ms, auto_submit) + }) + .await + .map_err(|e| OutputError::InjectionFailed(format!("Task join error: {}", e)))??; + + tracing::info!("Text typed via CGEvent ({} chars)", text.chars().count()); + + if self.notify { + self.send_notification(text).await; + } + + Ok(()) + } + + async fn is_available(&self) -> bool { + // CGEvent is available on macOS, return true to allow helpful error message + // if permissions are denied + true + } + + fn name(&self) -> &'static str { + "cgevent (macOS native)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let output = CGEventOutput::new(10, 100, true, false); + assert_eq!(output.type_delay_ms, 10); + assert_eq!(output.pre_type_delay_ms, 100); + assert!(output.notify); + assert!(!output.auto_submit); + } + + #[test] + fn test_new_with_auto_submit() { + let output = CGEventOutput::new(0, 0, false, true); + assert!(!output.notify); + assert!(output.auto_submit); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index bdc84ef6..9c95b30e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -3,6 +3,8 @@ //! Provides text output via keyboard simulation or clipboard. //! //! Fallback chain for `mode = "type"`: +//! +//! Linux: //! 1. wtype - Wayland-native via virtual-keyboard protocol, best Unicode/CJK support, no daemon needed //! 2. eitype - Wayland via libei/EI protocol, works on GNOME/KDE (no virtual-keyboard support) //! 3. dotool - Works on X11/Wayland/TTY, supports keyboard layouts, no daemon needed @@ -10,12 +12,23 @@ //! 5. clipboard (wl-copy) - Wayland clipboard fallback //! 6. xclip - X11 clipboard fallback //! +//! macOS: +//! 1. cgevent - Native CGEvent API for keyboard simulation (best performance) +//! 2. osascript - AppleScript fallback +//! 3. pbcopy - Native macOS clipboard +//! //! Paste mode (clipboard + Ctrl+V) helps with system with non US keyboard layouts. +#[cfg(target_os = "macos")] +pub mod cgevent; pub mod clipboard; pub mod dotool; pub mod eitype; +#[cfg(target_os = "macos")] +pub mod osascript; pub mod paste; +#[cfg(target_os = "macos")] +pub mod pbcopy; pub mod post_process; pub mod wtype; pub mod xclip; @@ -210,49 +223,78 @@ pub fn create_output_chain_with_override( match config.mode { crate::config::OutputMode::Type => { - // Determine driver order: CLI override > config > default - let driver_order: &[OutputDriver] = driver_override - .or(config.driver_order.as_deref()) - .unwrap_or(DEFAULT_DRIVER_ORDER); - - if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { - tracing::info!( - "Using custom driver order: {}", - custom_order - .iter() - .map(|d| d.to_string()) - .collect::>() - .join(" -> ") - ); - } - - // Build chain based on driver order - for (i, driver) in driver_order.iter().enumerate() { - // Skip clipboard if it's in the middle and fallback_to_clipboard is false - // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) - let is_last = i == driver_order.len() - 1; - if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard { - continue; - } + #[cfg(target_os = "macos")] + { + // macOS: Primary - CGEvent (native API, best performance) + // driver_order not yet supported on macOS + chain.push(Box::new(cgevent::CGEventOutput::new( + config.type_delay_ms, + pre_type_delay_ms, + config.auto_submit, + ))); - chain.push(create_driver_output( - *driver, - config, + // Fallback 1: osascript (AppleScript, works without CGEvent permissions) + chain.push(Box::new(osascript::OsascriptOutput::new( + config.auto_submit, pre_type_delay_ms, - i == 0, - )); + ))); + + // Fallback 2: pbcopy for clipboard + if config.fallback_to_clipboard { + chain.push(Box::new(pbcopy::PbcopyOutput::new())); + } } - // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it - if config.fallback_to_clipboard - && config.driver_order.is_some() - && !driver_order.contains(&OutputDriver::Clipboard) + #[cfg(not(target_os = "macos"))] { - chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + // Determine driver order: CLI override > config > default + let driver_order: &[OutputDriver] = driver_override + .or(config.driver_order.as_deref()) + .unwrap_or(DEFAULT_DRIVER_ORDER); + + if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { + tracing::info!( + "Using custom driver order: {}", + custom_order + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(" -> ") + ); + } + + // Build chain based on driver order + for (i, driver) in driver_order.iter().enumerate() { + // Skip clipboard if it's in the middle and fallback_to_clipboard is false + // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) + let is_last = i == driver_order.len() - 1; + if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard { + continue; + } + + chain.push(create_driver_output( + *driver, + config, + pre_type_delay_ms, + i == 0, + )); + } + + // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it + if config.fallback_to_clipboard + && config.driver_order.is_some() + && !driver_order.contains(&OutputDriver::Clipboard) + { + chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + } } } crate::config::OutputMode::Clipboard => { - // Only clipboard + // Clipboard mode + #[cfg(target_os = "macos")] + chain.push(Box::new(pbcopy::PbcopyOutput::new())); + + #[cfg(not(target_os = "macos"))] chain.push(Box::new(clipboard::ClipboardOutput::new( config.notification.on_transcription, ))); diff --git a/src/output/osascript.rs b/src/output/osascript.rs new file mode 100644 index 00000000..c2286a19 --- /dev/null +++ b/src/output/osascript.rs @@ -0,0 +1,184 @@ +//! macOS text output via osascript/AppleScript +//! +//! Uses System Events to simulate keyboard input on macOS. +//! Requires Accessibility permissions for the terminal/app running voxtype. +//! +//! This is the primary typing method on macOS. + +use super::TextOutput; +use crate::error::OutputError; +use std::process::Stdio; +use tokio::process::Command; + +/// macOS text output using osascript +pub struct OsascriptOutput { + /// Whether to show a desktop notification + notify: bool, + /// Whether to send Enter key after text + auto_submit: bool, + /// Delay before typing starts (ms) + pre_type_delay_ms: u32, +} + +impl OsascriptOutput { + /// Create a new osascript output + pub fn new(notify: bool, auto_submit: bool, pre_type_delay_ms: u32) -> Self { + Self { + notify, + auto_submit, + pre_type_delay_ms, + } + } + + /// Send a desktop notification using osascript + async fn send_notification(&self, text: &str) { + // Truncate preview for notification + let preview = if text.chars().count() > 80 { + format!("{}...", text.chars().take(80).collect::()) + } else { + text.to_string() + }; + + // Escape for AppleScript string + let escaped_preview = preview.replace('\\', "\\\\").replace('"', "\\\""); + + let script = format!( + r#"display notification "{}" with title "Voxtype""#, + escaped_preview + ); + + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } + + /// Escape text for AppleScript string literal + fn escape_for_applescript(text: &str) -> String { + text.replace('\\', "\\\\").replace('"', "\\\"") + } +} + +/// Wait for all modifier keys (Option, Command, Control, Shift) to be released +/// This prevents typing garbage characters when hotkey uses a modifier +async fn wait_for_modifiers_release() { + // Simple fixed delay - the AppleScript check was causing issues + // 150ms is enough for the Option key to fully release + tokio::time::sleep(std::time::Duration::from_millis(150)).await; +} + +#[async_trait::async_trait] +impl TextOutput for OsascriptOutput { + async fn output(&self, text: &str) -> Result<(), OutputError> { + if text.is_empty() { + return Ok(()); + } + + // Wait for modifier keys to be released (prevents Option-key garbage) + wait_for_modifiers_release().await; + + // Additional pre-type delay if configured + if self.pre_type_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + self.pre_type_delay_ms as u64, + )) + .await; + } + + // Escape text for AppleScript + let escaped_text = Self::escape_for_applescript(text); + + // Build AppleScript to type text + // Using "keystroke" which types the text character by character + let mut script = format!( + r#"tell application "System Events" to keystroke "{}""#, + escaped_text + ); + + // Add Enter key if auto_submit is enabled + if self.auto_submit { + script.push_str(r#" +tell application "System Events" to key code 36"#); // 36 = Return key + } + + let output = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + OutputError::InjectionFailed("osascript not found".to_string()) + } else { + OutputError::InjectionFailed(e.to_string()) + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // Check for common permission error + if stderr.contains("not allowed") || stderr.contains("accessibility") { + return Err(OutputError::InjectionFailed( + "Accessibility permission required. Grant access in System Settings > Privacy & Security > Accessibility".to_string() + )); + } + return Err(OutputError::InjectionFailed(format!( + "osascript failed: {}", + stderr + ))); + } + + // Send notification if enabled + if self.notify { + self.send_notification(text).await; + } + + tracing::info!("Text typed via osascript ({} chars)", text.len()); + Ok(()) + } + + async fn is_available(&self) -> bool { + // osascript is always available on macOS + cfg!(target_os = "macos") + && Command::new("which") + .arg("osascript") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + } + + fn name(&self) -> &'static str { + "osascript (macOS)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let output = OsascriptOutput::new(true, false, 0); + assert!(output.notify); + assert!(!output.auto_submit); + assert_eq!(output.pre_type_delay_ms, 0); + } + + #[test] + fn test_escape_for_applescript() { + assert_eq!( + OsascriptOutput::escape_for_applescript(r#"hello "world""#), + r#"hello \"world\""# + ); + assert_eq!( + OsascriptOutput::escape_for_applescript(r#"path\to\file"#), + r#"path\\to\\file"# + ); + } +} diff --git a/src/output/pbcopy.rs b/src/output/pbcopy.rs new file mode 100644 index 00000000..d82761b9 --- /dev/null +++ b/src/output/pbcopy.rs @@ -0,0 +1,133 @@ +//! macOS clipboard output via pbcopy +//! +//! Uses the native macOS pbcopy command for clipboard access. +//! This is the clipboard fallback on macOS. + +use super::TextOutput; +use crate::error::OutputError; +use std::process::Stdio; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +/// macOS clipboard output using pbcopy +pub struct PbcopyOutput { + /// Whether to show a desktop notification + notify: bool, +} + +impl PbcopyOutput { + /// Create a new pbcopy output + pub fn new(notify: bool) -> Self { + Self { notify } + } + + /// Send a desktop notification using osascript + async fn send_notification(&self, text: &str) { + // Truncate preview for notification + let preview = if text.chars().count() > 80 { + format!("{}...", text.chars().take(80).collect::()) + } else { + text.to_string() + }; + + // Escape for AppleScript string + let escaped_preview = preview.replace('\\', "\\\\").replace('"', "\\\""); + + let script = format!( + r#"display notification "{}" with title "Copied to clipboard""#, + escaped_preview + ); + + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } +} + +#[async_trait::async_trait] +impl TextOutput for PbcopyOutput { + async fn output(&self, text: &str) -> Result<(), OutputError> { + if text.is_empty() { + return Ok(()); + } + + // Spawn pbcopy with stdin pipe + let mut child = Command::new("pbcopy") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + OutputError::InjectionFailed("pbcopy not found".to_string()) + } else { + OutputError::InjectionFailed(e.to_string()) + } + })?; + + // Write text to stdin + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(text.as_bytes()) + .await + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + + // Close stdin to signal EOF + drop(stdin); + } + + // Wait for completion + let status = child + .wait() + .await + .map_err(|e| OutputError::InjectionFailed(e.to_string()))?; + + if !status.success() { + return Err(OutputError::InjectionFailed( + "pbcopy exited with error".to_string(), + )); + } + + // Send notification if enabled + if self.notify { + self.send_notification(text).await; + } + + tracing::info!("Text copied to clipboard via pbcopy ({} chars)", text.len()); + Ok(()) + } + + async fn is_available(&self) -> bool { + // pbcopy is always available on macOS + cfg!(target_os = "macos") + && Command::new("which") + .arg("pbcopy") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await + .map(|s| s.success()) + .unwrap_or(false) + } + + fn name(&self) -> &'static str { + "clipboard (pbcopy)" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let output = PbcopyOutput::new(true); + assert!(output.notify); + + let output = PbcopyOutput::new(false); + assert!(!output.notify); + } +} diff --git a/src/output/post_process.rs b/src/output/post_process.rs index 195d57ee..abf131d5 100644 --- a/src/output/post_process.rs +++ b/src/output/post_process.rs @@ -198,8 +198,9 @@ mod tests { #[tokio::test] async fn test_empty_output_fallback() { - // echo -n outputs nothing, which should trigger fallback - let config = make_config("echo -n ''", 5000); + // printf '' outputs nothing, which should trigger fallback + // (echo -n is not portable across platforms) + let config = make_config("printf ''", 5000); let processor = PostProcessor::new(&config); let result = processor.process("original text").await; assert_eq!(result, "original text"); // Falls back to original @@ -232,7 +233,8 @@ mod tests { #[tokio::test] async fn test_whitespace_trimming() { // Output has trailing newline which should be trimmed - let config = make_config("echo 'hello'", 5000); + // Use printf with \n to be portable across platforms + let config = make_config("printf 'hello\\n'", 5000); let processor = PostProcessor::new(&config); let result = processor.process("ignored").await; assert_eq!(result, "hello"); diff --git a/src/setup/hammerspoon.rs b/src/setup/hammerspoon.rs new file mode 100644 index 00000000..9ab3f1c4 --- /dev/null +++ b/src/setup/hammerspoon.rs @@ -0,0 +1,127 @@ +//! Hammerspoon integration setup for macOS +//! +//! Helps users configure Hammerspoon for hotkey support as an alternative +//! to the built-in rdev-based hotkey capture. + +use std::path::PathBuf; + +/// Generate the Hammerspoon init.lua snippet +fn generate_config(hotkey: &str, toggle: bool) -> String { + let mode = if toggle { "toggle" } else { "push_to_talk" }; + format!( + r#"-- Voxtype Hammerspoon Integration +-- Add this to your ~/.hammerspoon/init.lua + +local voxtype = require("voxtype") +voxtype.setup({{ + hotkey = "{}", + mode = "{}", +}}) + +-- Optional: Add a cancel hotkey +-- voxtype.add_cancel_hotkey({{"cmd", "shift"}}, "escape") +"#, + hotkey, mode + ) +} + +/// Get the path to the Hammerspoon config directory +fn hammerspoon_dir() -> Option { + dirs::home_dir().map(|h| h.join(".hammerspoon")) +} + +/// Check if Hammerspoon is installed +async fn is_hammerspoon_installed() -> bool { + tokio::process::Command::new("which") + .arg("hs") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) + || std::path::Path::new("/Applications/Hammerspoon.app").exists() +} + +/// Install the voxtype.lua module to ~/.hammerspoon/ +async fn install_module() -> anyhow::Result<()> { + let hs_dir = hammerspoon_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + + // Create .hammerspoon directory if needed + if !hs_dir.exists() { + std::fs::create_dir_all(&hs_dir)?; + println!("Created {}", hs_dir.display()); + } + + // Write the voxtype.lua module + let module_path = hs_dir.join("voxtype.lua"); + let module_content = include_str!("../../contrib/hammerspoon/voxtype.lua"); + std::fs::write(&module_path, module_content)?; + println!("Installed {}", module_path.display()); + + Ok(()) +} + +/// Run the Hammerspoon setup command +pub async fn run(install: bool, show: bool, hotkey: &str, toggle: bool) -> anyhow::Result<()> { + println!("Hammerspoon Integration for Voxtype"); + println!("====================================\n"); + + // Show config if requested (even without Hammerspoon installed) + if show { + println!("Add the following to your ~/.hammerspoon/init.lua:\n"); + println!("{}", generate_config(hotkey, toggle)); + return Ok(()); + } + + // Check if Hammerspoon is installed for other actions + if !is_hammerspoon_installed().await { + println!("Hammerspoon is not installed.\n"); + println!("Install it with:"); + println!(" brew install --cask hammerspoon\n"); + println!("Then run this command again.\n"); + println!("Or use --show to see the config snippet anyway."); + return Ok(()); + } + + if install { + // Install the module + install_module().await?; + println!(); + println!("Now add the following to your ~/.hammerspoon/init.lua:"); + println!(); + println!("{}", generate_config(hotkey, toggle)); + println!(); + println!("Then reload Hammerspoon config:"); + println!(" - Click Hammerspoon menu bar icon -> Reload Config"); + println!(" - Or press Cmd+Shift+R while Hammerspoon console is focused"); + } else if show { + // Just show the config + println!("Add the following to your ~/.hammerspoon/init.lua:\n"); + println!("{}", generate_config(hotkey, toggle)); + } else { + // Default: show instructions + println!("Hammerspoon provides hotkey support without granting Accessibility"); + println!("permissions to Terminal.\n"); + + println!("Setup options:\n"); + println!(" voxtype setup hammerspoon --install"); + println!(" Install voxtype.lua module and show config snippet\n"); + println!(" voxtype setup hammerspoon --show"); + println!(" Show the init.lua configuration snippet\n"); + println!(" voxtype setup hammerspoon --install --hotkey rightcmd"); + println!(" Install with a different hotkey\n"); + println!(" voxtype setup hammerspoon --install --toggle"); + println!(" Use toggle mode (press to start/stop) instead of push-to-talk\n"); + + println!("Available hotkeys:"); + println!(" rightalt, leftalt, rightcmd, leftcmd, rightctrl, leftctrl"); + println!(" rightshift, leftshift, f1-f20, escape, space, tab, etc.\n"); + + println!("Current Hammerspoon directory: {}", + hammerspoon_dir() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "not found".to_string()) + ); + } + + Ok(()) +} diff --git a/src/setup/launchd.rs b/src/setup/launchd.rs new file mode 100644 index 00000000..1da87492 --- /dev/null +++ b/src/setup/launchd.rs @@ -0,0 +1,245 @@ +//! macOS LaunchAgent service installation +//! +//! Provides commands to install, uninstall, and check the status of +//! voxtype as a launchd user service on macOS. + +use super::{get_voxtype_path, print_failure, print_info, print_success, print_warning}; +use std::fs; +use std::path::PathBuf; + +const PLIST_FILENAME: &str = "io.voxtype.daemon.plist"; + +/// Get the path to the LaunchAgents directory +fn launch_agents_dir() -> Option { + dirs::home_dir().map(|home| home.join("Library/LaunchAgents")) +} + +/// Get the path to the plist file +fn plist_path() -> Option { + launch_agents_dir().map(|dir| dir.join(PLIST_FILENAME)) +} + +/// Get the path to the logs directory +fn logs_dir() -> Option { + dirs::home_dir().map(|home| home.join("Library/Logs/voxtype")) +} + +/// Generate the launchd plist content +fn generate_plist() -> String { + let voxtype_path = get_voxtype_path(); + let logs_dir = logs_dir().unwrap_or_else(|| PathBuf::from("/tmp")); + + format!( + r#" + + + + Label + io.voxtype.daemon + + ProgramArguments + + {voxtype_path} + daemon + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {stdout} + + StandardErrorPath + {stderr} + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + + + ProcessType + Interactive + + Nice + -10 + + +"#, + voxtype_path = voxtype_path, + stdout = logs_dir.join("stdout.log").display(), + stderr = logs_dir.join("stderr.log").display(), + ) +} + +/// Install the LaunchAgent +pub async fn install() -> anyhow::Result<()> { + println!("Installing Voxtype LaunchAgent...\n"); + + // Check if we're on macOS + if !cfg!(target_os = "macos") { + print_failure("This command is only available on macOS"); + print_info("On Linux, use: voxtype setup systemd"); + anyhow::bail!("Not on macOS"); + } + + // Ensure LaunchAgents directory exists + let launch_dir = launch_agents_dir().ok_or_else(|| anyhow::anyhow!("Could not determine LaunchAgents directory"))?; + fs::create_dir_all(&launch_dir)?; + + // Ensure logs directory exists + if let Some(logs) = logs_dir() { + fs::create_dir_all(&logs)?; + print_success(&format!("Logs directory: {:?}", logs)); + } + + // Generate and write the plist + let plist = plist_path().ok_or_else(|| anyhow::anyhow!("Could not determine plist path"))?; + let content = generate_plist(); + fs::write(&plist, &content)?; + print_success(&format!("Created: {:?}", plist)); + + // Load the service + let status = std::process::Command::new("launchctl") + .args(["load", plist.to_str().unwrap_or("")]) + .status(); + + match status { + Ok(s) if s.success() => { + print_success("LaunchAgent loaded"); + } + _ => { + print_warning("Could not load LaunchAgent automatically"); + print_info("Try: launchctl load ~/Library/LaunchAgents/io.voxtype.daemon.plist"); + } + } + + println!("\n---"); + println!("\x1b[32m✓ Installation complete!\x1b[0m"); + println!(); + println!("Voxtype will now start automatically on login."); + println!(); + println!("Useful commands:"); + println!(" launchctl start io.voxtype.daemon - Start now"); + println!(" launchctl stop io.voxtype.daemon - Stop"); + println!(" launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist - Disable"); + println!(); + println!("Logs:"); + if let Some(logs) = logs_dir() { + println!(" tail -f {:?}/stdout.log", logs); + println!(" tail -f {:?}/stderr.log", logs); + } + + Ok(()) +} + +/// Uninstall the LaunchAgent +pub async fn uninstall() -> anyhow::Result<()> { + println!("Uninstalling Voxtype LaunchAgent...\n"); + + let plist = plist_path().ok_or_else(|| anyhow::anyhow!("Could not determine plist path"))?; + + if !plist.exists() { + print_info("LaunchAgent not installed"); + return Ok(()); + } + + // Unload the service first + let _ = std::process::Command::new("launchctl") + .args(["unload", plist.to_str().unwrap_or("")]) + .status(); + + // Remove the plist file + fs::remove_file(&plist)?; + print_success("LaunchAgent removed"); + + println!("\n---"); + println!("\x1b[32m✓ Uninstallation complete!\x1b[0m"); + + Ok(()) +} + +/// Show LaunchAgent status +pub async fn status() -> anyhow::Result<()> { + println!("Voxtype LaunchAgent Status\n"); + println!("==========================\n"); + + let plist = plist_path().ok_or_else(|| anyhow::anyhow!("Could not determine plist path"))?; + + // Check if plist exists + if plist.exists() { + print_success(&format!("Plist installed: {:?}", plist)); + } else { + print_failure("LaunchAgent not installed"); + print_info("Install with: voxtype setup launchd"); + return Ok(()); + } + + // Check if service is running + let output = std::process::Command::new("launchctl") + .args(["list", "io.voxtype.daemon"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("io.voxtype.daemon") { + print_success("Service is running"); + + // Parse PID if available + for line in stdout.lines() { + if let Some(pid) = line.split_whitespace().next() { + if pid != "-" { + println!(" PID: {}", pid); + } + } + } + } else { + print_warning("Service is loaded but not running"); + } + } + _ => { + print_warning("Service is not loaded"); + print_info("Start with: launchctl load ~/Library/LaunchAgents/io.voxtype.daemon.plist"); + } + } + + // Show log locations + if let Some(logs) = logs_dir() { + println!("\nLogs:"); + let stdout_log = logs.join("stdout.log"); + let stderr_log = logs.join("stderr.log"); + + if stdout_log.exists() { + let size = fs::metadata(&stdout_log) + .map(|m| m.len()) + .unwrap_or(0); + println!(" stdout: {:?} ({} bytes)", stdout_log, size); + } + if stderr_log.exists() { + let size = fs::metadata(&stderr_log) + .map(|m| m.len()) + .unwrap_or(0); + println!(" stderr: {:?} ({} bytes)", stderr_log, size); + } + } + + Ok(()) +} + +/// Regenerate the LaunchAgent plist file (e.g., after binary path change) +/// Returns true if the file was updated +pub fn regenerate_plist() -> anyhow::Result { + let plist = match plist_path() { + Some(p) if p.exists() => p, + _ => return Ok(false), + }; + + let content = generate_plist(); + fs::write(&plist, &content)?; + + Ok(true) +} diff --git a/src/setup/macos.rs b/src/setup/macos.rs new file mode 100644 index 00000000..d539006f --- /dev/null +++ b/src/setup/macos.rs @@ -0,0 +1,352 @@ +//! macOS interactive setup wizard +//! +//! Provides a guided setup experience for macOS users, covering: +//! - Accessibility permission checks +//! - Hotkey configuration (native rdev or Hammerspoon) +//! - Menu bar setup +//! - LaunchAgent auto-start +//! - Model download + +use super::{print_failure, print_info, print_success, print_warning}; +use std::io::{self, Write}; + +/// Check if Terminal/iTerm has Accessibility permission +async fn check_accessibility_permission() -> bool { + // Try to use osascript to check if we can control System Events + let output = tokio::process::Command::new("osascript") + .args(["-e", "tell application \"System Events\" to return name of first process"]) + .output() + .await; + + match output { + Ok(o) => o.status.success(), + Err(_) => false, + } +} + +/// Check if Hammerspoon is installed +async fn check_hammerspoon() -> bool { + std::path::Path::new("/Applications/Hammerspoon.app").exists() + || tokio::process::Command::new("which") + .arg("hs") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Check if terminal-notifier is installed +async fn check_terminal_notifier() -> bool { + tokio::process::Command::new("which") + .arg("terminal-notifier") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Get user input with a default value +fn prompt(message: &str, default: &str) -> String { + print!("{} [{}]: ", message, default); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim(); + + if input.is_empty() { + default.to_string() + } else { + input.to_string() + } +} + +/// Get yes/no input +fn prompt_yn(message: &str, default: bool) -> bool { + let default_str = if default { "Y/n" } else { "y/N" }; + print!("{} [{}]: ", message, default_str); + io::stdout().flush().unwrap(); + + let mut input = String::new(); + io::stdin().read_line(&mut input).unwrap(); + let input = input.trim().to_lowercase(); + + match input.as_str() { + "y" | "yes" => true, + "n" | "no" => false, + "" => default, + _ => default, + } +} + +/// Print a section header +fn section(title: &str) { + println!("\n\x1b[1m{}\x1b[0m", title); + println!("{}", "─".repeat(title.len())); +} + +/// Check if a notification icon is installed +fn check_notification_icon() -> bool { + let candidates = [ + dirs::data_dir().map(|d| d.join("voxtype/icon.png")), + dirs::config_dir().map(|d| d.join("voxtype/icon.png")), + ]; + + candidates.into_iter().flatten().any(|p| p.exists()) +} + +/// Install a default notification icon +fn install_default_icon_file() -> anyhow::Result<()> { + // Create the data directory + let data_dir = dirs::data_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find Application Support directory"))? + .join("voxtype"); + + std::fs::create_dir_all(&data_dir)?; + + let icon_path = data_dir.join("icon.png"); + + // Create a simple microphone icon as PNG + // This is a 64x64 PNG with a microphone glyph + // Base64-encoded PNG data for a simple blue microphone icon + let icon_data = include_bytes!("../../assets/icon.png"); + std::fs::write(&icon_path, icon_data)?; + + println!(" Installed icon: {}", icon_path.display()); + Ok(()) +} + +/// Run the macOS setup wizard +pub async fn run() -> anyhow::Result<()> { + println!("\x1b[1mVoxtype macOS Setup Wizard\x1b[0m"); + println!("==========================\n"); + println!("This wizard will guide you through setting up Voxtype on macOS.\n"); + + // Step 1: Check system requirements + section("Step 1: System Requirements"); + + // Check macOS version + let macos_version = tokio::process::Command::new("sw_vers") + .args(["-productVersion"]) + .output() + .await + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|_| "Unknown".to_string()); + print_success(&format!("macOS version: {}", macos_version)); + + // Check accessibility permission + print!(" Checking Accessibility permission... "); + io::stdout().flush().unwrap(); + let has_accessibility = check_accessibility_permission().await; + println!(); + + if has_accessibility { + print_success("Accessibility permission granted"); + } else { + print_warning("Accessibility permission not granted"); + println!(); + println!(" To enable typing output, grant Accessibility permission to your terminal:"); + println!(" 1. Open System Settings → Privacy & Security → Accessibility"); + println!(" 2. Add your terminal app (Terminal, iTerm2, Alacritty, etc.)"); + println!(" 3. Restart your terminal after granting permission"); + println!(); + println!(" Alternatively, use Hammerspoon for hotkey support (no terminal permission needed)"); + } + + // Check terminal-notifier + let has_notifier = check_terminal_notifier().await; + if has_notifier { + print_success("terminal-notifier installed (enhanced notifications)"); + } else { + print_info("terminal-notifier not installed (optional, for better notifications)"); + println!(" Install with: brew install terminal-notifier"); + } + + // Step 2: Hotkey configuration + section("Step 2: Hotkey Configuration"); + + let has_hammerspoon = check_hammerspoon().await; + + println!("Voxtype supports two methods for global hotkey capture:\n"); + println!(" 1. Native (rdev) - Built-in, requires Accessibility permission for terminal"); + println!(" 2. Hammerspoon - External app, doesn't require terminal permission\n"); + + if has_hammerspoon { + print_success("Hammerspoon is installed"); + } else { + print_info("Hammerspoon is not installed"); + println!(" Install with: brew install --cask hammerspoon"); + } + + let use_hammerspoon = if has_hammerspoon { + println!(); + prompt_yn("Use Hammerspoon for hotkey support?", !has_accessibility) + } else { + false + }; + + let hotkey = prompt("\nHotkey to use", "rightalt"); + let toggle_mode = prompt_yn("Use toggle mode? (press to start/stop instead of hold to record)", false); + + if use_hammerspoon { + println!(); + println!("Setting up Hammerspoon integration..."); + + // Install the Hammerspoon module + if let Err(e) = super::hammerspoon::run(true, false, &hotkey, toggle_mode).await { + print_warning(&format!("Could not set up Hammerspoon: {}", e)); + } + } else { + print_success(&format!("Configured native hotkey: {}", hotkey)); + print_info(&format!("Mode: {}", if toggle_mode { "toggle" } else { "push-to-talk" })); + + if !has_accessibility { + println!(); + print_warning("Remember to grant Accessibility permission to your terminal!"); + } + } + + // Step 3: Menu bar + section("Step 3: Menu Bar Integration"); + + println!("The menu bar helper shows recording status and provides quick controls.\n"); + + let setup_menubar = prompt_yn("Set up menu bar helper?", true); + + if setup_menubar { + print_success("Menu bar helper will be available via: voxtype menubar"); + print_info("You can add it to LaunchAgent for auto-start (next step)"); + } + + // Step 4: Auto-start + section("Step 4: Auto-start Configuration"); + + println!("Voxtype can start automatically when you log in.\n"); + + let setup_autostart = prompt_yn("Set up auto-start (LaunchAgent)?", true); + + if setup_autostart { + println!(); + println!("Installing LaunchAgent..."); + + if let Err(e) = super::launchd::install().await { + print_warning(&format!("Could not install LaunchAgent: {}", e)); + } + } + + // Step 5: Notification icon + section("Step 5: Notification Icon (Optional)"); + + if has_notifier { + println!("terminal-notifier supports custom notification icons.\n"); + + let icon_installed = check_notification_icon(); + if icon_installed { + print_success("Custom notification icon is installed"); + } else { + print_info("No custom notification icon found"); + println!(); + println!(" To add a custom icon, place a PNG file at one of:"); + println!(" - ~/Library/Application Support/voxtype/icon.png"); + println!(" - ~/.config/voxtype/icon.png"); + println!(); + + let install_default_icon = prompt_yn("Install a default microphone icon?", true); + if install_default_icon { + if let Err(e) = install_default_icon_file() { + print_warning(&format!("Could not install icon: {}", e)); + } else { + print_success("Default icon installed"); + } + } + } + } else { + print_info("Install terminal-notifier to enable custom notification icons"); + } + + // Step 6: Model download + section("Step 6: Whisper Model"); + + // Load config to get current model + let config = crate::config::load_config(None).unwrap_or_default(); + let current_model = &config.whisper.model; + + println!("Voxtype uses Whisper for speech recognition.\n"); + println!("Available models (from fastest to most accurate):"); + println!(" tiny.en - Fastest, English only (~75 MB)"); + println!(" base.en - Fast, English only (~145 MB)"); + println!(" small.en - Balanced, English only (~500 MB)"); + println!(" medium.en - Accurate, English only (~1.5 GB)"); + println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); + println!(); + println!("Current model: {}", current_model); + + let model = prompt("\nModel to use", current_model); + + // Check if model is downloaded + let models_dir = crate::Config::models_dir(); + let model_filename = crate::transcribe::whisper::get_model_filename(&model); + let model_path = models_dir.join(&model_filename); + + if model_path.exists() { + print_success(&format!("Model '{}' is already downloaded", model)); + } else { + let download = prompt_yn(&format!("Download model '{}'?", model), true); + if download { + println!(); + println!("Downloading model... (this may take a while)"); + if let Err(e) = super::model::download_model(&model) { + print_failure(&format!("Download failed: {}", e)); + } else { + print_success("Model downloaded successfully"); + + // Update config to use the new model + if let Err(e) = super::model::set_model_config(&model) { + print_warning(&format!("Could not update config: {}", e)); + } + } + } + } + + // Summary + section("Setup Complete!"); + + println!("Your voxtype installation is ready. Here's a summary:\n"); + + if use_hammerspoon { + println!(" Hotkey method: Hammerspoon"); + println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); + } else { + println!(" Hotkey method: Native (rdev)"); + println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); + } + println!(" Model: {}", model); + println!(" Menu bar: {}", if setup_menubar { "enabled" } else { "disabled" }); + println!(" Auto-start: {}", if setup_autostart { "enabled" } else { "disabled" }); + + println!("\n\x1b[1mNext steps:\x1b[0m\n"); + + if !setup_autostart { + println!(" 1. Start the daemon: voxtype daemon"); + } else { + println!(" 1. The daemon will start automatically (or run: voxtype daemon)"); + } + + if setup_menubar { + println!(" 2. Start the menu bar: voxtype menubar"); + } + + if use_hammerspoon { + println!(" 3. Reload Hammerspoon config (click menu bar icon → Reload Config)"); + } + + println!(); + println!("Then just press {} to start recording!", hotkey); + + if !has_accessibility && !use_hammerspoon { + println!(); + print_warning("Don't forget to grant Accessibility permission to your terminal!"); + } + + Ok(()) +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index d23411dc..25900557 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -12,6 +12,11 @@ pub mod compositor; pub mod dms; pub mod gpu; +#[cfg(target_os = "macos")] +pub mod hammerspoon; +pub mod launchd; +#[cfg(target_os = "macos")] +pub mod macos; pub mod model; pub mod parakeet; pub mod systemd; @@ -26,6 +31,7 @@ use tokio::process::Command; pub enum DisplayServer { Wayland, X11, + MacOS, Unknown, } @@ -34,6 +40,7 @@ impl std::fmt::Display for DisplayServer { match self { DisplayServer::Wayland => write!(f, "Wayland"), DisplayServer::X11 => write!(f, "X11"), + DisplayServer::MacOS => write!(f, "macOS"), DisplayServer::Unknown => write!(f, "Unknown"), } } @@ -59,6 +66,9 @@ pub struct OutputChainStatus { pub ydotool_daemon: bool, pub wl_copy: OutputToolStatus, pub xclip: OutputToolStatus, + // macOS-specific + pub osascript: OutputToolStatus, + pub pbcopy: OutputToolStatus, pub primary_method: Option, } @@ -118,15 +128,24 @@ pub fn print_warning(msg: &str) { /// Detect the current display server pub fn detect_display_server() -> DisplayServer { - // Check for Wayland first - if std::env::var("WAYLAND_DISPLAY").is_ok() { - return DisplayServer::Wayland; + // Check for macOS first + #[cfg(target_os = "macos")] + { + return DisplayServer::MacOS; } - // Check for X11 - if std::env::var("DISPLAY").is_ok() { - return DisplayServer::X11; + + #[cfg(not(target_os = "macos"))] + { + // Check for Wayland first + if std::env::var("WAYLAND_DISPLAY").is_ok() { + return DisplayServer::Wayland; + } + // Check for X11 + if std::env::var("DISPLAY").is_ok() { + return DisplayServer::X11; + } + DisplayServer::Unknown } - DisplayServer::Unknown } /// Get the path to a command if it exists @@ -224,13 +243,39 @@ pub async fn detect_output_chain() -> OutputChainStatus { None }; + // Check osascript (macOS) + let osascript_path = get_command_path("osascript").await; + let osascript_installed = osascript_path.is_some(); + let osascript_available = osascript_installed && display_server == DisplayServer::MacOS; + let osascript_note = if osascript_installed && !osascript_available { + Some("macOS only".to_string()) + } else if osascript_available { + Some("requires Accessibility permission".to_string()) + } else { + None + }; + + // Check pbcopy (macOS) + let pbcopy_path = get_command_path("pbcopy").await; + let pbcopy_installed = pbcopy_path.is_some(); + let pbcopy_available = pbcopy_installed && display_server == DisplayServer::MacOS; + let pbcopy_note = if pbcopy_installed && !pbcopy_available { + Some("macOS only".to_string()) + } else { + None + }; + // Determine primary method - let primary_method = if wtype_available { + let primary_method = if osascript_available { + Some("osascript".to_string()) + } else if wtype_available { Some("wtype".to_string()) } else if eitype_available { Some("eitype".to_string()) } else if ydotool_available { Some("ydotool".to_string()) + } else if pbcopy_available { + Some("pbcopy".to_string()) } else if wl_copy_available || xclip_available { Some("clipboard".to_string()) } else { @@ -275,6 +320,20 @@ pub async fn detect_output_chain() -> OutputChainStatus { path: xclip_path, note: xclip_note, }, + osascript: OutputToolStatus { + name: "osascript", + installed: osascript_installed, + available: osascript_available, + path: osascript_path, + note: osascript_note, + }, + pbcopy: OutputToolStatus { + name: "pbcopy", + installed: pbcopy_installed, + available: pbcopy_available, + path: pbcopy_path, + note: pbcopy_note, + }, primary_method, } } @@ -293,61 +352,63 @@ pub fn print_output_chain_status(status: &OutputChainStatus) { let display = std::env::var("DISPLAY").unwrap_or_default(); format!("X11 (DISPLAY={})", display) } + DisplayServer::MacOS => "macOS (Quartz)".to_string(), DisplayServer::Unknown => "Unknown (no WAYLAND_DISPLAY or DISPLAY set)".to_string(), }; println!(" Display server: {}", ds_info); - // wtype - print_tool_status( - &status.wtype, - status.display_server == DisplayServer::Wayland, - ); - - // eitype - print_tool_status( - &status.eitype, - status.display_server == DisplayServer::Wayland, - ); - - // ydotool - if status.ydotool.installed { - let daemon_status = if status.ydotool_daemon { - "\x1b[32mdaemon running\x1b[0m" - } else { - "\x1b[31mdaemon not running\x1b[0m" - }; - if let Some(ref path) = status.ydotool.path { - if status.ydotool.available { - println!( - " ydotool: \x1b[32m✓\x1b[0m installed ({}), {}", - path, daemon_status - ); + // Show platform-specific tools + if status.display_server == DisplayServer::MacOS { + // macOS tools + print_tool_status(&status.osascript, true); + print_tool_status(&status.pbcopy, true); + } else { + // Linux tools + // wtype + print_tool_status(&status.wtype, status.display_server == DisplayServer::Wayland); + + // eitype + print_tool_status(&status.eitype, status.display_server == DisplayServer::Wayland); + + // ydotool + if status.ydotool.installed { + let daemon_status = if status.ydotool_daemon { + "\x1b[32mdaemon running\x1b[0m" } else { - println!( - " ydotool: \x1b[33m⚠\x1b[0m installed ({}), {}", - path, daemon_status - ); + "\x1b[31mdaemon not running\x1b[0m" + }; + if let Some(ref path) = status.ydotool.path { + if status.ydotool.available { + println!( + " ydotool: \x1b[32m✓\x1b[0m installed ({}), {}", + path, daemon_status + ); + } else { + println!( + " ydotool: \x1b[33m⚠\x1b[0m installed ({}), {}", + path, daemon_status + ); + } } + } else { + println!(" ydotool: \x1b[31m✗\x1b[0m not installed"); } - } else { - println!(" ydotool: \x1b[31m✗\x1b[0m not installed"); - } - // wl-copy - print_tool_status( - &status.wl_copy, - status.display_server == DisplayServer::Wayland, - ); + // wl-copy + print_tool_status(&status.wl_copy, status.display_server == DisplayServer::Wayland); - // xclip (only show on X11 or if installed) - if status.display_server == DisplayServer::X11 || status.xclip.installed { - print_tool_status(&status.xclip, status.display_server == DisplayServer::X11); + // xclip (only show on X11 or if installed) + if status.display_server == DisplayServer::X11 || status.xclip.installed { + print_tool_status(&status.xclip, status.display_server == DisplayServer::X11); + } } // Summary println!(); if let Some(ref method) = status.primary_method { let method_desc = match method.as_str() { + "osascript" => "osascript (AppleScript/System Events)", + "pbcopy" => "pbcopy (clipboard, requires manual paste)", "wtype" => "wtype (CJK supported)", "eitype" => "eitype (libei, GNOME/KDE native)", "ydotool" => "ydotool (CJK not supported)", @@ -357,7 +418,11 @@ pub fn print_output_chain_status(status: &OutputChainStatus) { println!(" \x1b[32m→\x1b[0m Text will be typed via {}", method_desc); } else { println!(" \x1b[31m→\x1b[0m No text output method available!"); - println!(" Install wtype (Wayland), eitype (GNOME/KDE), or ydotool (X11) for typing support"); + if status.display_server == DisplayServer::MacOS { + println!(" osascript should be available on macOS"); + } else { + println!(" Install wtype (Wayland), eitype (GNOME/KDE), or ydotool (X11) for typing support"); + } } } @@ -462,8 +527,8 @@ pub async fn run_setup( .map(|name| model::is_parakeet_model(name)) .unwrap_or(false); - // Use model_override if provided, otherwise use config default (for Whisper) - let model_name: &str = match model_override { + // Validate model_override if provided (variable unused after this, each branch re-defines) + let _model_name: &str = match model_override { Some(name) => { // Validate the model name (check both Whisper and Parakeet) if !model::is_valid_model(name) && !model::is_parakeet_model(name) { diff --git a/src/setup/model.rs b/src/setup/model.rs index af58181d..910bbb3f 100644 --- a/src/setup/model.rs +++ b/src/setup/model.rs @@ -144,6 +144,7 @@ pub async fn interactive_select() -> anyhow::Result<()> { println!("=======================\n"); let models_dir = Config::models_dir(); + println!("Models directory: {:?}\n", models_dir); // Load current config to determine active model diff --git a/src/transcribe/subprocess.rs b/src/transcribe/subprocess.rs index 549b039d..e9dbd5be 100644 --- a/src/transcribe/subprocess.rs +++ b/src/transcribe/subprocess.rs @@ -173,7 +173,7 @@ impl SubprocessTranscriber { let samples_bytes = unsafe { std::slice::from_raw_parts( samples.as_ptr() as *const u8, - samples.len() * std::mem::size_of::(), + std::mem::size_of_val(samples), ) }; stdin.write_all(samples_bytes).map_err(|e| { diff --git a/src/transcribe/whisper.rs b/src/transcribe/whisper.rs index a0c18ace..dc636da6 100644 --- a/src/transcribe/whisper.rs +++ b/src/transcribe/whisper.rs @@ -182,6 +182,10 @@ impl Transcriber for WhisperTranscriber { params.set_suppress_blank(true); params.set_suppress_nst(true); + // Prevent hallucination/looping by not conditioning on previous text + // This is especially important for short clips where Whisper can repeat itself + params.set_no_context(true); + // Set initial prompt if configured if let Some(prompt) = &self.initial_prompt { params.set_initial_prompt(prompt); diff --git a/website/appcast.xml b/website/appcast.xml new file mode 100644 index 00000000..b8316d84 --- /dev/null +++ b/website/appcast.xml @@ -0,0 +1,36 @@ + + + + Voxtype Updates + https://voxtype.io/appcast.xml + Push-to-talk voice-to-text for macOS + en + + + + Version 0.5.0 + 0.5.0 + 0.5.0 + 11.0 + Mon, 20 Jan 2026 00:00:00 +0000 + Voxtype 0.5.0 - macOS Release +

First official macOS release of Voxtype!

+
    +
  • Universal binary (Intel + Apple Silicon)
  • +
  • Metal GPU acceleration for fast transcription
  • +
  • LaunchAgent for automatic startup
  • +
+

For more details, visit voxtype.io/news

+ ]]>
+ +
+ + + +
+
diff --git a/website/index.html b/website/index.html index f61740d2..eb3296e0 100644 --- a/website/index.html +++ b/website/index.html @@ -1034,7 +1034,7 @@

Legal

From 8410568ac056ef2181ffc51b5e78000e69f61b4c Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 17:45:07 -0500 Subject: [PATCH 02/33] macOS: Add SwiftUI setup wizard and improve daemon - Add native SwiftUI setup app (macos/VoxtypeSetup/) - Setup wizard: permissions, model download, LaunchAgent - Preferences panel for ongoing configuration - Calls voxtype CLI for actual operations - Improve menubar and notification handling - Enhance macOS setup CLI as fallback - Update config and error handling for macOS --- Cargo.lock | 58 +- Cargo.toml | 5 +- macos/README.md | 45 ++ macos/RELEASE_PLAN.md | 135 ++++ macos/VoxtypeSetup/.gitignore | 4 + macos/VoxtypeSetup/Package.swift | 18 + .../Sources/Preferences/PreferencesView.swift | 325 ++++++++ .../Sources/SetupWizard/CompleteView.swift | 113 +++ .../Sources/SetupWizard/LaunchAgentView.swift | 144 ++++ .../SetupWizard/ModelSelectionView.swift | 194 +++++ .../Sources/SetupWizard/PermissionsView.swift | 165 ++++ .../Sources/SetupWizard/SetupWizardView.swift | 84 ++ .../Sources/SetupWizard/WelcomeView.swift | 84 ++ .../Sources/Utilities/PermissionChecker.swift | 105 +++ .../Sources/Utilities/VoxtypeCLI.swift | 270 +++++++ .../Sources/VoxtypeSetupApp.swift | 68 ++ macos/VoxtypeSetup/build-app.sh | 66 ++ src/config.rs | 20 +- src/daemon.rs | 15 +- src/error.rs | 1 + src/hotkey_macos.rs | 7 +- src/main.rs | 29 +- src/menubar.rs | 562 ++++++++++++- src/notification.rs | 139 +--- src/output/mod.rs | 9 +- src/setup/macos.rs | 742 +++++++++++++++--- 26 files changed, 3099 insertions(+), 308 deletions(-) create mode 100644 macos/README.md create mode 100644 macos/RELEASE_PLAN.md create mode 100644 macos/VoxtypeSetup/.gitignore create mode 100644 macos/VoxtypeSetup/Package.swift create mode 100644 macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift create mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift create mode 100644 macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift create mode 100644 macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift create mode 100644 macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift create mode 100644 macos/VoxtypeSetup/build-app.sh diff --git a/Cargo.lock b/Cargo.lock index 6a13fe0a..2d94aa3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -700,6 +700,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -1786,6 +1795,18 @@ version = "0.15.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede" +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "mach2" version = "0.4.3" @@ -2102,6 +2123,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + [[package]] name = "num-derive" version = "0.4.2" @@ -2228,6 +2255,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2391,9 +2419,9 @@ dependencies = [ [[package]] name = "parakeet-rs" -version = "0.2.9" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7667842fd2f3b97b029a30fb9a00138867c6915229f5acd6bd809d08250d2ee" +checksum = "6cbd5310b3d9a1d8ab59369a2e6dd20511f46a81de08f5aaca0ba811059c2c93" dependencies = [ "eyre", "hound", @@ -2515,6 +2543,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3273,6 +3307,25 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + [[package]] name = "tinystr" version = "0.8.2" @@ -3663,6 +3716,7 @@ dependencies = [ "hound", "inotify 0.10.2", "libc", + "mac-notification-sys", "nix 0.29.0", "notify", "num_cpus", diff --git a/Cargo.toml b/Cargo.toml index c91ba708..b8cd8f27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,7 +72,7 @@ rodio = { version = "0.19", default-features = false, features = ["wav"] } whisper-rs = "0.15.1" # Parakeet speech-to-text (optional, ONNX-based) -parakeet-rs = { version = "0.2.9", optional = true } +parakeet-rs = { version = "0.3", optional = true } # CPU count for thread detection @@ -88,6 +88,8 @@ pidlock = "0.1" tao = "0.32" core-graphics = "0.24" core-foundation = "0.10" +dirs = "5" +mac-notification-sys = "0.6" # Native macOS notifications [target.'cfg(target_os = "linux")'.dependencies] # Input handling (evdev for kernel-level key events) @@ -106,6 +108,7 @@ parakeet = ["dep:parakeet-rs"] parakeet-cuda = ["parakeet", "parakeet-rs/cuda"] parakeet-tensorrt = ["parakeet", "parakeet-rs/tensorrt"] parakeet-rocm = ["parakeet", "parakeet-rs/rocm"] +parakeet-coreml = ["parakeet", "parakeet-rs/coreml"] # Dynamic loading for system ONNX Runtime (used by Nix builds) parakeet-load-dynamic = ["parakeet", "parakeet-rs/load-dynamic"] diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 00000000..2be5fee2 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,45 @@ +# Voxtype macOS + +This directory contains macOS-specific code, separate from the cross-platform Rust core. + +## VoxtypeSetup + +A native SwiftUI app that provides: + +1. **Setup Wizard** - First-run experience that guides users through: + - Granting permissions (Microphone, Accessibility, Input Monitoring) + - Downloading a speech model + - Installing the LaunchAgent for auto-start + +2. **Preferences** - Settings panel for changing: + - Speech engine (Parakeet vs Whisper) + - Model selection + - Auto-start toggle + - Daemon control + +## Building + +```bash +cd macos/VoxtypeSetup +swift build -c release + +# Or open in Xcode +open Package.swift +``` + +## Architecture + +The SwiftUI app is a thin GUI layer. All actual functionality is delegated to the +`voxtype` Rust binary via CLI calls: + +- `VoxtypeCLI.swift` - Wrapper that calls voxtype commands +- `PermissionChecker.swift` - Native macOS permission checking + +This keeps business logic in Rust while providing a native Mac experience. + +## Distribution + +The setup app can be: +1. Bundled inside Voxtype.app as a helper +2. Distributed separately as VoxtypeSetup.app +3. Invoked via `voxtype setup macos --gui` (if integrated) diff --git a/macos/RELEASE_PLAN.md b/macos/RELEASE_PLAN.md new file mode 100644 index 00000000..65be01d3 --- /dev/null +++ b/macos/RELEASE_PLAN.md @@ -0,0 +1,135 @@ +# macOS Release Plan + +Status: In Progress +Branch: feature/macos-release +Target: Merge to main, Homebrew distribution, then signed distribution + +--- + +## Phase 1: Rebase and Linux Validation + +### 1.1 Rebase onto main (v0.5.3) +- [ ] `git fetch origin` +- [ ] `git rebase origin/main` +- [ ] Resolve any conflicts +- [ ] Verify build after rebase + +### 1.2 Validate Linux compilation +- [ ] Run `cargo check` (quick syntax/type check) +- [ ] Run `cargo build --release` on Linux (via Docker or remote) +- [ ] Run `cargo test` to verify no regressions +- [ ] Confirm macOS-specific code is properly gated with `#[cfg(target_os = "macos")]` + +--- + +## Phase 2: macOS Build and Homebrew + +### 2.1 Build macOS binary +- [ ] `cargo build --release` on macOS +- [ ] Verify binary works: `./target/release/voxtype --version` +- [ ] Test basic functionality (record, transcribe, output) + +### 2.2 Build SwiftUI Setup App +- [ ] `cd macos/VoxtypeSetup && ./build-app.sh` +- [ ] Test setup wizard flow +- [ ] Test preferences panel + +### 2.3 Create Homebrew formula +- [ ] Create formula in homebrew-voxtype tap +- [ ] Test `brew install --build-from-source` +- [ ] Test `brew install` from bottle (if available) + +--- + +## Phase 3: Signed Distribution + +### 3.1 Apple Developer Setup +- [ ] Ensure Apple Developer account is active +- [ ] Create/verify Developer ID Application certificate +- [ ] Create/verify Developer ID Installer certificate (for pkg) +- [ ] Set up notarization credentials (app-specific password or API key) + +### 3.2 Code Signing +- [ ] Sign voxtype binary with Developer ID +- [ ] Sign VoxtypeSetup.app with Developer ID +- [ ] Verify signatures: `codesign -dv --verbose=4` + +### 3.3 Notarization +- [ ] Submit for notarization: `xcrun notarytool submit` +- [ ] Wait for approval +- [ ] Staple ticket: `xcrun stapler staple` + +### 3.4 Distribution Package (choose one or both) + +#### Option A: DMG Installer +- [ ] Create DMG with app bundle and symlink to /Applications +- [ ] Sign DMG +- [ ] Notarize DMG +- [ ] Test fresh install on clean Mac + +#### Option B: Mac App Store (more restrictive) +- [ ] Create App Store Connect record +- [ ] Add required entitlements +- [ ] Sandbox compliance (may require significant changes) +- [ ] Submit for review + +**Recommendation:** Start with DMG. App Store sandboxing may conflict with: +- Accessibility permission requirements +- Input monitoring +- LaunchAgent installation +- Calling external binaries + +--- + +## Current State + +### Completed +- [x] Basic macOS daemon functionality +- [x] LaunchAgent for auto-start +- [x] Hotkey detection via rdev +- [x] Audio capture via cpal +- [x] Text output via Accessibility API +- [x] Notifications +- [x] SwiftUI Setup App scaffolded (needs testing) + +### In Progress +- [ ] Rebase onto v0.5.3 + +### Blocked +- [ ] Signed distribution (needs Phase 1-2 complete) + +--- + +## Commands Reference + +```bash +# Rebase +git fetch origin && git rebase origin/main + +# Linux check (Docker) +docker run --rm -v $(pwd):/src -w /src rust:latest cargo check + +# macOS build +cargo build --release + +# SwiftUI app build +cd macos/VoxtypeSetup && ./build-app.sh + +# Sign binary +codesign --force --options runtime --sign "Developer ID Application: YOUR NAME" target/release/voxtype + +# Notarize +xcrun notarytool submit app.zip --apple-id EMAIL --team-id TEAM --password APP_PASSWORD --wait + +# Staple +xcrun stapler staple Voxtype.app +``` + +--- + +## Notes + +- SwiftUI app requires macOS 13+ +- Homebrew formula should handle both Intel and Apple Silicon +- DMG is simpler for initial release; App Store can come later +- Keep CLI setup as fallback for power users / Homebrew installs diff --git a/macos/VoxtypeSetup/.gitignore b/macos/VoxtypeSetup/.gitignore new file mode 100644 index 00000000..54346f50 --- /dev/null +++ b/macos/VoxtypeSetup/.gitignore @@ -0,0 +1,4 @@ +.build/ +.swiftpm/ +*.xcodeproj/xcuserdata/ +DerivedData/ diff --git a/macos/VoxtypeSetup/Package.swift b/macos/VoxtypeSetup/Package.swift new file mode 100644 index 00000000..9bdbe728 --- /dev/null +++ b/macos/VoxtypeSetup/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "VoxtypeSetup", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "VoxtypeSetup", targets: ["VoxtypeSetup"]) + ], + targets: [ + .executableTarget( + name: "VoxtypeSetup", + path: "Sources" + ) + ] +) diff --git a/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift b/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift new file mode 100644 index 00000000..e34a0714 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift @@ -0,0 +1,325 @@ +import SwiftUI + +struct PreferencesView: View { + @EnvironmentObject var setupState: SetupState + @StateObject private var cli = VoxtypeCLI.shared + @StateObject private var permissions = PermissionChecker.shared + + @State private var selectedEngine: TranscriptionEngine = .parakeet + @State private var selectedModel: String = "" + @State private var autoStartEnabled: Bool = false + @State private var showingModelDownload = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Image(systemName: "mic.circle.fill") + .font(.title) + .foregroundColor(.accentColor) + Text("Voxtype Preferences") + .font(.title2) + .fontWeight(.semibold) + Spacer() + + // Status indicator + HStack(spacing: 6) { + Circle() + .fill(daemonRunning ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text(daemonRunning ? "Running" : "Stopped") + .font(.callout) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + + Divider() + + ScrollView { + VStack(spacing: 24) { + // Engine & Model + PreferenceSection(title: "Speech Recognition", icon: "waveform") { + VStack(alignment: .leading, spacing: 12) { + Text("Engine") + .font(.callout) + .foregroundColor(.secondary) + + Picker("Engine", selection: $selectedEngine) { + Text("Parakeet").tag(TranscriptionEngine.parakeet) + Text("Whisper").tag(TranscriptionEngine.whisper) + } + .pickerStyle(.segmented) + .onChange(of: selectedEngine) { _ in + _ = cli.setEngine(selectedEngine) + } + + Text("Model") + .font(.callout) + .foregroundColor(.secondary) + .padding(.top, 8) + + HStack { + Text(selectedModel.isEmpty ? "No model selected" : selectedModel) + .foregroundColor(selectedModel.isEmpty ? .secondary : .primary) + Spacer() + Button("Change...") { + showingModelDownload = true + } + } + } + } + + // Permissions + PreferenceSection(title: "Permissions", icon: "lock.shield") { + VStack(spacing: 12) { + PermissionStatusRow( + title: "Microphone", + isGranted: permissions.hasMicrophoneAccess, + action: { permissions.requestMicrophoneAccess { _ in } } + ) + PermissionStatusRow( + title: "Accessibility", + isGranted: permissions.hasAccessibilityAccess, + action: { permissions.openAccessibilitySettings() } + ) + PermissionStatusRow( + title: "Input Monitoring", + isGranted: permissions.hasInputMonitoringAccess, + action: { permissions.openInputMonitoringSettings() } + ) + } + } + + // Auto-start + PreferenceSection(title: "Startup", icon: "arrow.clockwise") { + Toggle(isOn: $autoStartEnabled) { + Text("Start Voxtype at login") + } + .onChange(of: autoStartEnabled) { newValue in + if newValue { + _ = cli.installLaunchAgent() + } else { + _ = cli.uninstallLaunchAgent() + } + } + } + + // Daemon control + PreferenceSection(title: "Daemon", icon: "gearshape.2") { + HStack { + Button("Restart Daemon") { + cli.restartDaemon() + } + + Button("Stop Daemon") { + cli.stopDaemon() + } + + Spacer() + + Button("View Logs") { + let logPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/voxtype") + NSWorkspace.shared.open(logPath) + } + } + } + + // Config file + PreferenceSection(title: "Advanced", icon: "doc.text") { + HStack { + Text("For advanced settings, edit the config file directly") + .font(.callout) + .foregroundColor(.secondary) + Spacer() + Button("Open Config") { + let configPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/voxtype/config.toml") + NSWorkspace.shared.open(configPath) + } + } + } + } + .padding() + } + + Divider() + + // Footer + HStack { + Button("Run Setup Again") { + setupState.setupComplete = false + setupState.currentStep = .welcome + } + .buttonStyle(.borderless) + + Spacer() + + Button("Quit") { + NSApplication.shared.terminate(nil) + } + } + .padding() + } + .frame(width: 500, height: 600) + .onAppear { + loadCurrentSettings() + } + .sheet(isPresented: $showingModelDownload) { + ModelDownloadSheet(selectedEngine: selectedEngine) { model in + selectedModel = model + } + } + } + + var daemonRunning: Bool { + cli.getStatus() != "stopped" && !cli.getStatus().isEmpty + } + + func loadCurrentSettings() { + autoStartEnabled = cli.hasLaunchAgent() + permissions.refresh() + + // Load current engine and model from config + if let config = cli.getConfig() { + if let engine = config["engine"] as? String { + selectedEngine = engine == "parakeet" ? .parakeet : .whisper + } + // TODO: Extract current model from config + } + } +} + +struct PreferenceSection: View { + let title: String + let icon: String + let content: () -> Content + + init(title: String, icon: String, @ViewBuilder content: @escaping () -> Content) { + self.title = title + self.icon = icon + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Image(systemName: icon) + .foregroundColor(.accentColor) + Text(title) + .fontWeight(.semibold) + } + + content() + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + } + } +} + +struct PermissionStatusRow: View { + let title: String + let isGranted: Bool + let action: () -> Void + + var body: some View { + HStack { + Text(title) + Spacer() + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Button("Grant") { + action() + } + .controlSize(.small) + } + } + } +} + +struct ModelDownloadSheet: View { + let selectedEngine: TranscriptionEngine + let onSelect: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @StateObject private var cli = VoxtypeCLI.shared + @State private var selectedModel: String = "" + @State private var isDownloading = false + + var models: [ModelInfo] { + cli.availableModels().filter { $0.engine == selectedEngine } + } + + var body: some View { + VStack(spacing: 20) { + Text("Select Model") + .font(.title2) + .fontWeight(.semibold) + + List(selection: $selectedModel) { + ForEach(models) { model in + HStack { + VStack(alignment: .leading) { + Text(model.name) + .fontWeight(.medium) + Text(model.description) + .font(.callout) + .foregroundColor(.secondary) + } + Spacer() + Text(model.size) + .foregroundColor(.secondary) + } + .tag(model.name) + } + } + .frame(height: 200) + + if isDownloading { + ProgressView(value: cli.downloadProgress) + .progressViewStyle(.linear) + } + + HStack { + Button("Cancel") { + dismiss() + } + + Spacer() + + Button("Download & Use") { + downloadAndUse() + } + .disabled(selectedModel.isEmpty || isDownloading) + } + } + .padding() + .frame(width: 400) + } + + func downloadAndUse() { + isDownloading = true + Task { + do { + try await cli.downloadModel(selectedModel, engine: selectedEngine) + _ = cli.setModel(selectedModel, engine: selectedEngine) + await MainActor.run { + onSelect(selectedModel) + dismiss() + } + } catch { + isDownloading = false + } + } + } +} + +#Preview { + PreferencesView() + .environmentObject(SetupState()) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift new file mode 100644 index 00000000..c544478c --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct CompleteView: View { + @EnvironmentObject var setupState: SetupState + + var body: some View { + VStack(spacing: 30) { + Spacer() + + Image(systemName: "checkmark.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundColor(.green) + + VStack(spacing: 12) { + Text("You're All Set!") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Voxtype is ready to use") + .font(.title3) + .foregroundColor(.secondary) + } + + // Usage instructions + VStack(alignment: .leading, spacing: 20) { + InstructionRow( + step: "1", + title: "Hold your hotkey", + description: "By default, hold the Right Option key to start recording" + ) + + InstructionRow( + step: "2", + title: "Speak clearly", + description: "Talk normally - you'll see the orange mic indicator in your menu bar" + ) + + InstructionRow( + step: "3", + title: "Release to transcribe", + description: "Let go of the key and your speech will be typed at your cursor" + ) + } + .padding(.horizontal, 60) + .padding(.vertical, 20) + + // Hotkey reminder + HStack { + Image(systemName: "keyboard") + .foregroundColor(.accentColor) + Text("Default hotkey: ") + .foregroundColor(.secondary) + Text("Right Option (⌥)") + .fontWeight(.medium) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + + Spacer() + + // Actions + HStack(spacing: 20) { + Button("Open Preferences") { + setupState.setupComplete = true + } + .buttonStyle(WizardButtonStyle()) + + Button("Start Using Voxtype") { + NSApplication.shared.terminate(nil) + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + } + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + } +} + +struct InstructionRow: View { + let step: String + let title: String + let description: String + + var body: some View { + HStack(alignment: .top, spacing: 16) { + Text(step) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + .frame(width: 32, height: 32) + .background(Color.accentColor) + .clipShape(Circle()) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + Text(description) + .font(.callout) + .foregroundColor(.secondary) + } + + Spacer() + } + } +} + +#Preview { + CompleteView() + .environmentObject(SetupState()) + .frame(width: 600, height: 500) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift new file mode 100644 index 00000000..f6291f64 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +struct LaunchAgentView: View { + @EnvironmentObject var setupState: SetupState + @StateObject private var cli = VoxtypeCLI.shared + + @State private var enableAutoStart = true + @State private var isInstalling = false + @State private var installComplete = false + @State private var errorMessage: String? + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Image(systemName: "arrow.clockwise.circle.fill") + .resizable() + .frame(width: 60, height: 60) + .foregroundColor(.accentColor) + + Text("Auto-Start") + .font(.title) + .fontWeight(.bold) + + Text("Would you like Voxtype to start automatically when you log in?") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.horizontal, 60) + + VStack(spacing: 16) { + Toggle(isOn: $enableAutoStart) { + VStack(alignment: .leading, spacing: 4) { + Text("Start Voxtype at Login") + .fontWeight(.medium) + Text("Voxtype will run in the background and be ready whenever you need it") + .font(.callout) + .foregroundColor(.secondary) + } + } + .toggleStyle(.switch) + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + } + .padding(.horizontal, 40) + + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.callout) + } + + if installComplete { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text(enableAutoStart ? "Auto-start enabled!" : "Skipped auto-start") + .foregroundColor(.green) + } + } + + // Info box + HStack(alignment: .top, spacing: 12) { + Image(systemName: "info.circle") + .foregroundColor(.blue) + VStack(alignment: .leading, spacing: 4) { + Text("How it works") + .fontWeight(.medium) + Text("Voxtype runs as a background service. It listens for your hotkey and transcribes speech when triggered. You can always start/stop it manually from Terminal.") + .font(.callout) + .foregroundColor(.secondary) + } + } + .padding() + .background(Color.blue.opacity(0.1)) + .cornerRadius(10) + .padding(.horizontal, 40) + + Spacer() + + // Navigation + HStack { + Button("Back") { + withAnimation { + setupState.currentStep = .model + } + } + .buttonStyle(WizardButtonStyle()) + .disabled(isInstalling) + + Spacer() + + Button("Continue") { + installAndContinue() + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + .disabled(isInstalling) + } + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + } + + func installAndContinue() { + isInstalling = true + errorMessage = nil + + DispatchQueue.global(qos: .userInitiated).async { + var success = true + + if enableAutoStart { + success = cli.installLaunchAgent() + } + + DispatchQueue.main.async { + isInstalling = false + + if success { + installComplete = true + + // Start the daemon + if enableAutoStart { + cli.startDaemon() + } + + // Move to complete after a brief delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation { + setupState.currentStep = .complete + } + } + } else { + errorMessage = "Failed to install auto-start. You can set this up later." + } + } + } + } +} + +#Preview { + LaunchAgentView() + .environmentObject(SetupState()) + .frame(width: 600, height: 500) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift new file mode 100644 index 00000000..7c6f8362 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift @@ -0,0 +1,194 @@ +import SwiftUI + +struct ModelSelectionView: View { + @EnvironmentObject var setupState: SetupState + @StateObject private var cli = VoxtypeCLI.shared + + @State private var selectedEngine: TranscriptionEngine = .parakeet + @State private var selectedModel: String = "parakeet-tdt-0.6b-v3-int8" + @State private var isDownloading = false + @State private var downloadComplete = false + @State private var errorMessage: String? + + var filteredModels: [ModelInfo] { + cli.availableModels().filter { $0.engine == selectedEngine } + } + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Text("Choose Speech Model") + .font(.title) + .fontWeight(.bold) + + Text("Select the speech recognition engine and model to use.") + .foregroundColor(.secondary) + + // Engine picker + Picker("Engine", selection: $selectedEngine) { + Text("Parakeet (Recommended for Mac)").tag(TranscriptionEngine.parakeet) + Text("Whisper (Multilingual)").tag(TranscriptionEngine.whisper) + } + .pickerStyle(.segmented) + .padding(.horizontal, 60) + .onChange(of: selectedEngine) { newValue in + // Select first model for the engine + selectedModel = filteredModels.first?.name ?? "" + } + + // Engine description + Text(engineDescription) + .font(.callout) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 60) + + // Model list + VStack(spacing: 8) { + ForEach(filteredModels) { model in + ModelRow( + model: model, + isSelected: selectedModel == model.name, + action: { selectedModel = model.name } + ) + } + } + .padding(.horizontal, 40) + + // Download progress + if isDownloading { + VStack(spacing: 8) { + ProgressView(value: cli.downloadProgress) + .progressViewStyle(.linear) + Text("Downloading \(selectedModel)... \(Int(cli.downloadProgress * 100))%") + .font(.callout) + .foregroundColor(.secondary) + } + .padding(.horizontal, 60) + } + + if let error = errorMessage { + Text(error) + .foregroundColor(.red) + .font(.callout) + } + + if downloadComplete { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Model downloaded successfully!") + .foregroundColor(.green) + } + } + + Spacer() + + // Navigation + HStack { + Button("Back") { + withAnimation { + setupState.currentStep = .permissions + } + } + .buttonStyle(WizardButtonStyle()) + .disabled(isDownloading) + + Spacer() + + if downloadComplete || cli.hasModel() { + Button("Continue") { + withAnimation { + setupState.currentStep = .launchAgent + } + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + } else { + Button("Download & Continue") { + downloadModel() + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + .disabled(isDownloading || selectedModel.isEmpty) + } + } + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + } + + var engineDescription: String { + switch selectedEngine { + case .parakeet: + return "Parakeet uses NVIDIA's FastConformer model, optimized for Apple Silicon. English only, but very fast." + case .whisper: + return "Whisper is OpenAI's speech recognition model. Supports many languages with excellent accuracy." + } + } + + func downloadModel() { + isDownloading = true + errorMessage = nil + + Task { + do { + try await cli.downloadModel(selectedModel, engine: selectedEngine) + await MainActor.run { + isDownloading = false + downloadComplete = true + // Also set the model in config + _ = cli.setEngine(selectedEngine) + _ = cli.setModel(selectedModel, engine: selectedEngine) + } + } catch { + await MainActor.run { + isDownloading = false + errorMessage = "Download failed. Please check your internet connection." + } + } + } + } +} + +struct ModelRow: View { + let model: ModelInfo + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(model.name) + .fontWeight(.medium) + Text(model.description) + .font(.callout) + .foregroundColor(.secondary) + } + + Spacer() + + Text(model.size) + .font(.callout) + .foregroundColor(.secondary) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundColor(isSelected ? .accentColor : .secondary) + } + .padding() + .background(isSelected ? Color.accentColor.opacity(0.1) : Color(NSColor.controlBackgroundColor)) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + ModelSelectionView() + .environmentObject(SetupState()) + .frame(width: 600, height: 500) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift new file mode 100644 index 00000000..4411b88c --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +struct PermissionsView: View { + @EnvironmentObject var setupState: SetupState + @StateObject private var permissions = PermissionChecker.shared + @State private var isCheckingPermissions = false + + var allPermissionsGranted: Bool { + permissions.hasMicrophoneAccess && + permissions.hasAccessibilityAccess && + permissions.hasInputMonitoringAccess + } + + var body: some View { + VStack(spacing: 20) { + Spacer() + + Text("Permissions Required") + .font(.title) + .fontWeight(.bold) + + Text("Voxtype needs a few permissions to work properly.\nClick each button to grant access.") + .multilineTextAlignment(.center) + .foregroundColor(.secondary) + .padding(.bottom, 10) + + VStack(spacing: 16) { + PermissionRow( + title: "Microphone", + description: "To capture your voice for transcription", + icon: "mic.fill", + isGranted: permissions.hasMicrophoneAccess, + action: { + permissions.requestMicrophoneAccess { _ in } + } + ) + + PermissionRow( + title: "Accessibility", + description: "To type transcribed text into applications", + icon: "accessibility", + isGranted: permissions.hasAccessibilityAccess, + action: { + permissions.requestAccessibilityAccess() + } + ) + + PermissionRow( + title: "Input Monitoring", + description: "To detect your push-to-talk hotkey", + icon: "keyboard", + isGranted: permissions.hasInputMonitoringAccess, + action: { + permissions.openInputMonitoringSettings() + } + ) + } + .padding(.horizontal, 40) + + // Refresh button + Button(action: { + isCheckingPermissions = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + permissions.refresh() + isCheckingPermissions = false + } + }) { + HStack { + if isCheckingPermissions { + ProgressView() + .scaleEffect(0.8) + .frame(width: 16, height: 16) + } else { + Image(systemName: "arrow.clockwise") + } + Text("Check Permissions") + } + } + .buttonStyle(.borderless) + .padding(.top, 10) + + if allPermissionsGranted { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("All permissions granted!") + .foregroundColor(.green) + } + .padding(.top, 10) + } + + Spacer() + + // Navigation + HStack { + Button("Back") { + withAnimation { + setupState.currentStep = .welcome + } + } + .buttonStyle(WizardButtonStyle()) + + Spacer() + + Button("Continue") { + withAnimation { + setupState.currentStep = .model + } + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + .disabled(!allPermissionsGranted) + } + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + } +} + +struct PermissionRow: View { + let title: String + let description: String + let icon: String + let isGranted: Bool + let action: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(isGranted ? .green : .accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + Text(description) + .font(.callout) + .foregroundColor(.secondary) + } + + Spacer() + + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title2) + } else { + Button("Grant Access") { + action() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + } +} + +#Preview { + PermissionsView() + .environmentObject(SetupState()) + .frame(width: 600, height: 500) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift new file mode 100644 index 00000000..1a7b2635 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct SetupWizardView: View { + @EnvironmentObject var setupState: SetupState + + var body: some View { + VStack(spacing: 0) { + // Progress indicator + ProgressBar(currentStep: setupState.currentStep) + .padding(.horizontal, 40) + .padding(.top, 30) + .padding(.bottom, 20) + + Divider() + + // Step content + Group { + switch setupState.currentStep { + case .welcome: + WelcomeView() + case .permissions: + PermissionsView() + case .model: + ModelSelectionView() + case .launchAgent: + LaunchAgentView() + case .complete: + CompleteView() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .frame(width: 600, height: 500) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +struct ProgressBar: View { + let currentStep: SetupStep + + var body: some View { + HStack(spacing: 0) { + ForEach(Array(SetupStep.allCases.enumerated()), id: \.element) { index, step in + if index > 0 { + Rectangle() + .fill(index <= currentStep.rawValue ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(height: 2) + } + + VStack(spacing: 4) { + Circle() + .fill(index <= currentStep.rawValue ? Color.accentColor : Color.secondary.opacity(0.3)) + .frame(width: 10, height: 10) + + Text(step.title) + .font(.caption2) + .foregroundColor(index == currentStep.rawValue ? .primary : .secondary) + } + .frame(width: 80) + } + } + } +} + +// MARK: - Navigation Button Style + +struct WizardButtonStyle: ButtonStyle { + var isPrimary: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(isPrimary ? Color.accentColor : Color.secondary.opacity(0.2)) + .foregroundColor(isPrimary ? .white : .primary) + .cornerRadius(8) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } +} + +#Preview { + SetupWizardView() + .environmentObject(SetupState()) +} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift new file mode 100644 index 00000000..67dea398 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct WelcomeView: View { + @EnvironmentObject var setupState: SetupState + + var body: some View { + VStack(spacing: 30) { + Spacer() + + // App icon placeholder + Image(systemName: "mic.circle.fill") + .resizable() + .frame(width: 80, height: 80) + .foregroundColor(.accentColor) + + VStack(spacing: 12) { + Text("Welcome to Voxtype") + .font(.largeTitle) + .fontWeight(.bold) + + Text("Push-to-talk voice transcription for macOS") + .font(.title3) + .foregroundColor(.secondary) + } + + VStack(alignment: .leading, spacing: 16) { + FeatureRow(icon: "hand.tap", title: "Push-to-Talk", + description: "Hold a key to record, release to transcribe") + FeatureRow(icon: "text.cursor", title: "Type Anywhere", + description: "Transcribed text appears at your cursor") + FeatureRow(icon: "bolt", title: "Fast & Private", + description: "Runs locally on your Mac, no cloud required") + } + .padding(.horizontal, 60) + .padding(.vertical, 20) + + Spacer() + + // Navigation + HStack { + Spacer() + Button("Get Started") { + withAnimation { + setupState.currentStep = .permissions + } + } + .buttonStyle(WizardButtonStyle(isPrimary: true)) + } + .padding(.horizontal, 40) + .padding(.bottom, 30) + } + } +} + +struct FeatureRow: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + Text(description) + .font(.callout) + .foregroundColor(.secondary) + } + + Spacer() + } + } +} + +#Preview { + WelcomeView() + .environmentObject(SetupState()) + .frame(width: 600, height: 500) +} diff --git a/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift b/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift new file mode 100644 index 00000000..4ce3290d --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift @@ -0,0 +1,105 @@ +import Foundation +import AVFoundation +import AppKit + +/// Checks and requests macOS permissions required by Voxtype +class PermissionChecker: ObservableObject { + static let shared = PermissionChecker() + + @Published var hasMicrophoneAccess: Bool = false + @Published var hasAccessibilityAccess: Bool = false + @Published var hasInputMonitoringAccess: Bool = false + + private init() { + refresh() + } + + /// Refresh all permission states + func refresh() { + checkMicrophoneAccess() + checkAccessibilityAccess() + checkInputMonitoringAccess() + } + + // MARK: - Microphone + + private func checkMicrophoneAccess() { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + hasMicrophoneAccess = true + case .notDetermined, .denied, .restricted: + hasMicrophoneAccess = false + @unknown default: + hasMicrophoneAccess = false + } + } + + func requestMicrophoneAccess(completion: @escaping (Bool) -> Void) { + AVCaptureDevice.requestAccess(for: .audio) { granted in + DispatchQueue.main.async { + self.hasMicrophoneAccess = granted + completion(granted) + } + } + } + + // MARK: - Accessibility + + private func checkAccessibilityAccess() { + hasAccessibilityAccess = AXIsProcessTrusted() + } + + func requestAccessibilityAccess() { + // This opens System Settings to the Accessibility pane + let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] + AXIsProcessTrustedWithOptions(options as CFDictionary) + + // Also open the settings pane directly for clarity + openAccessibilitySettings() + } + + func openAccessibilitySettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! + NSWorkspace.shared.open(url) + } + + // MARK: - Input Monitoring + + private func checkInputMonitoringAccess() { + // There's no direct API to check Input Monitoring permission + // We use a heuristic: try to create an event tap + // If it fails, we likely don't have permission + hasInputMonitoringAccess = canCreateEventTap() + } + + private func canCreateEventTap() -> Bool { + // Attempt to create a passive event tap + // This will fail if Input Monitoring permission is not granted + let eventMask = (1 << CGEventType.keyDown.rawValue) + guard let tap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(eventMask), + callback: { _, _, event, _ in Unmanaged.passUnretained(event) }, + userInfo: nil + ) else { + return false + } + // Clean up the tap + CFMachPortInvalidate(tap) + return true + } + + func openInputMonitoringSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")! + NSWorkspace.shared.open(url) + } + + // MARK: - Notifications (optional) + + func openNotificationSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications")! + NSWorkspace.shared.open(url) + } +} diff --git a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift new file mode 100644 index 00000000..c8218eda --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift @@ -0,0 +1,270 @@ +import Foundation + +/// Bridge to call the voxtype CLI binary +class VoxtypeCLI: ObservableObject { + static let shared = VoxtypeCLI() + + /// Path to the voxtype binary + private let binaryPath: String + + /// Current download progress (0.0 to 1.0) + @Published var downloadProgress: Double = 0.0 + @Published var isDownloading: Bool = false + @Published var downloadError: String? = nil + + private init() { + // Look for voxtype in standard locations + let candidates = [ + "/Applications/Voxtype.app/Contents/MacOS/voxtype", + "/usr/local/bin/voxtype", + "/opt/homebrew/bin/voxtype", + Bundle.main.bundlePath + "/Contents/MacOS/voxtype" + ] + + binaryPath = candidates.first { FileManager.default.fileExists(atPath: $0) } + ?? "/Applications/Voxtype.app/Contents/MacOS/voxtype" + } + + // MARK: - Status Checks + + /// Check if a speech model is downloaded + func hasModel() -> Bool { + let output = run(["status", "--json"]) + // If status works without error about missing model, we have one + return !output.contains("model not found") && !output.contains("No model") + } + + /// Check if LaunchAgent is installed + func hasLaunchAgent() -> Bool { + let plistPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/LaunchAgents/io.voxtype.daemon.plist") + return FileManager.default.fileExists(atPath: plistPath.path) + } + + /// Get current daemon status + func getStatus() -> String { + return run(["status"]).trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Get current configuration + func getConfig() -> [String: Any]? { + let output = run(["config", "--json"]) + guard let data = output.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return json + } + + // MARK: - Model Management + + /// Get list of available models + func availableModels() -> [ModelInfo] { + // Return hardcoded list for now - could parse from CLI later + return [ + ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, + description: "Fast, optimized for Apple Silicon", size: "670 MB"), + ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, + description: "Full precision", size: "1.2 GB"), + ModelInfo(name: "large-v3-turbo", engine: .whisper, + description: "Best accuracy, multilingual", size: "1.6 GB"), + ModelInfo(name: "medium.en", engine: .whisper, + description: "Good accuracy, English only", size: "1.5 GB"), + ModelInfo(name: "small.en", engine: .whisper, + description: "Balanced speed/accuracy", size: "500 MB"), + ModelInfo(name: "base.en", engine: .whisper, + description: "Fast, English only", size: "145 MB"), + ModelInfo(name: "tiny.en", engine: .whisper, + description: "Fastest, lower accuracy", size: "75 MB"), + ] + } + + /// Download a model (async with progress) + func downloadModel(_ model: String, engine: TranscriptionEngine) async throws { + await MainActor.run { + isDownloading = true + downloadProgress = 0.0 + downloadError = nil + } + + defer { + Task { @MainActor in + isDownloading = false + } + } + + let args: [String] + switch engine { + case .parakeet: + args = ["setup", "parakeet", "--download", model] + case .whisper: + args = ["setup", "model", "--download", model] + } + + // Run with progress monitoring + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = args + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + try process.run() + + // Monitor output for progress updates + let handle = pipe.fileHandleForReading + for try await line in handle.bytes.lines { + if let progress = parseProgress(line) { + await MainActor.run { + self.downloadProgress = progress + } + } + } + + process.waitUntilExit() + + if process.terminationStatus != 0 { + await MainActor.run { + self.downloadError = "Download failed" + } + throw CLIError.downloadFailed + } + + await MainActor.run { + downloadProgress = 1.0 + } + } + + private func parseProgress(_ line: String) -> Double? { + // Parse progress from CLI output like "Downloading... 45%" + if let range = line.range(of: #"(\d+)%"#, options: .regularExpression), + let percent = Double(line[range].dropLast()) { + return percent / 100.0 + } + return nil + } + + // MARK: - Configuration + + /// Set the transcription engine + func setEngine(_ engine: TranscriptionEngine) -> Bool { + let engineStr = engine == .parakeet ? "parakeet" : "whisper" + let output = run(["config", "set", "engine", engineStr]) + return !output.contains("error") + } + + /// Set the model + func setModel(_ model: String, engine: TranscriptionEngine) -> Bool { + let args: [String] + switch engine { + case .parakeet: + args = ["setup", "parakeet", "--set", model] + case .whisper: + args = ["setup", "model", "--set", model] + } + let output = run(args) + return !output.contains("error") + } + + // MARK: - LaunchAgent + + /// Install the LaunchAgent for auto-start + func installLaunchAgent() -> Bool { + let output = run(["setup", "launchd"]) + return !output.contains("error") && !output.contains("failed") + } + + /// Uninstall the LaunchAgent + func uninstallLaunchAgent() -> Bool { + let output = run(["setup", "launchd", "--uninstall"]) + return !output.contains("error") + } + + /// Start the daemon + func startDaemon() { + _ = run(["daemon"]) + } + + /// Stop the daemon + func stopDaemon() { + let _ = shell("pkill -f 'voxtype daemon'") + } + + /// Restart the daemon + func restartDaemon() { + stopDaemon() + Thread.sleep(forTimeInterval: 0.5) + startDaemon() + } + + // MARK: - Helpers + + /// Run a voxtype command and return output + private func run(_ arguments: [String]) -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: binaryPath) + process.arguments = arguments + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } catch { + return "error: \(error.localizedDescription)" + } + } + + /// Run a shell command + private func shell(_ command: String) -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-c", command] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return String(data: data, encoding: .utf8) ?? "" + } catch { + return "" + } + } +} + +// MARK: - Supporting Types + +enum TranscriptionEngine: String, CaseIterable { + case parakeet = "parakeet" + case whisper = "whisper" + + var displayName: String { + switch self { + case .parakeet: return "Parakeet" + case .whisper: return "Whisper" + } + } +} + +struct ModelInfo: Identifiable { + let id = UUID() + let name: String + let engine: TranscriptionEngine + let description: String + let size: String +} + +enum CLIError: Error { + case downloadFailed + case configFailed +} diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift new file mode 100644 index 00000000..554b81af --- /dev/null +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -0,0 +1,68 @@ +import SwiftUI + +@main +struct VoxtypeSetupApp: App { + @StateObject private var setupState = SetupState() + + var body: some Scene { + WindowGroup { + if setupState.setupComplete { + PreferencesView() + .environmentObject(setupState) + } else { + SetupWizardView() + .environmentObject(setupState) + } + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + } +} + +/// Tracks overall setup state +class SetupState: ObservableObject { + @Published var setupComplete: Bool = false + @Published var currentStep: SetupStep = .welcome + + init() { + // Check if setup has been completed before + setupComplete = checkSetupComplete() + } + + private func checkSetupComplete() -> Bool { + // Setup is complete if: + // 1. All permissions are granted + // 2. A model is downloaded + // 3. LaunchAgent is installed + let permissions = PermissionChecker.shared + let hasPermissions = permissions.hasMicrophoneAccess && + permissions.hasAccessibilityAccess && + permissions.hasInputMonitoringAccess + let hasModel = VoxtypeCLI.shared.hasModel() + let hasLaunchAgent = VoxtypeCLI.shared.hasLaunchAgent() + + return hasPermissions && hasModel && hasLaunchAgent + } + + func recheckSetup() { + setupComplete = checkSetupComplete() + } +} + +enum SetupStep: Int, CaseIterable { + case welcome + case permissions + case model + case launchAgent + case complete + + var title: String { + switch self { + case .welcome: return "Welcome" + case .permissions: return "Permissions" + case .model: return "Speech Model" + case .launchAgent: return "Auto-Start" + case .complete: return "Complete" + } + } +} diff --git a/macos/VoxtypeSetup/build-app.sh b/macos/VoxtypeSetup/build-app.sh new file mode 100644 index 00000000..d20d4685 --- /dev/null +++ b/macos/VoxtypeSetup/build-app.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Build VoxtypeSetup.app bundle + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Build release +swift build -c release + +# Create app bundle structure +APP_NAME="VoxtypeSetup" +APP_BUNDLE="$SCRIPT_DIR/.build/${APP_NAME}.app" +CONTENTS="$APP_BUNDLE/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +rm -rf "$APP_BUNDLE" +mkdir -p "$MACOS" "$RESOURCES" + +# Copy binary +cp ".build/release/$APP_NAME" "$MACOS/" + +# Create Info.plist +cat > "$CONTENTS/Info.plist" << 'EOF' + + + + + CFBundleExecutable + VoxtypeSetup + CFBundleIdentifier + io.voxtype.setup + CFBundleName + Voxtype Setup + CFBundleDisplayName + Voxtype Setup + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Voxtype needs microphone access for voice-to-text transcription. + NSAppleEventsUsageDescription + Voxtype needs to control other applications to type transcribed text. + + +EOF + +# Sign the app +codesign --force --deep --sign - "$APP_BUNDLE" + +echo "Built: $APP_BUNDLE" +echo "" +echo "To install:" +echo " cp -r $APP_BUNDLE /Applications/" +echo "" +echo "To run:" +echo " open $APP_BUNDLE" diff --git a/src/config.rs b/src/config.rs index cdae097d..25c39414 100644 --- a/src/config.rs +++ b/src/config.rs @@ -867,17 +867,31 @@ impl Default for ParakeetConfig { } /// Transcription engine selection (which ASR technology to use) -#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, Default)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum TranscriptionEngine { - /// Use Whisper (whisper.cpp via whisper-rs) - default - #[default] + /// Use Whisper (whisper.cpp via whisper-rs) Whisper, /// Use Parakeet (NVIDIA's FastConformer via ONNX Runtime) /// Requires: cargo build --features parakeet Parakeet, } +impl Default for TranscriptionEngine { + fn default() -> Self { + // macOS: Default to Parakeet with CoreML for best performance on Apple Silicon + // Linux: Default to Whisper for broader compatibility + #[cfg(target_os = "macos")] + { + TranscriptionEngine::Parakeet + } + #[cfg(not(target_os = "macos"))] + { + TranscriptionEngine::Whisper + } + } +} + /// Text processing configuration #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct TextConfig { diff --git a/src/daemon.rs b/src/daemon.rs index 95f5e394..8897bfa4 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -20,9 +20,9 @@ use crate::text::TextProcessor; use crate::transcribe::Transcriber; use pidlock::Pidlock; use std::path::PathBuf; -#[cfg(unix)] +#[cfg(target_os = "linux")] use nix::sys::signal::{kill, Signal}; -#[cfg(unix)] +#[cfg(target_os = "linux")] use nix::unistd::Pid; use std::sync::Arc; use std::time::Duration; @@ -87,14 +87,21 @@ fn write_pid_file() -> Option { Some(pid_path) } -/// Check if a PID is still running -#[cfg(unix)] +/// Check if a PID is still running (Linux version using nix) +#[cfg(target_os = "linux")] fn is_pid_running(pid: i32) -> bool { // kill with signal 0 checks if process exists without sending a signal kill(Pid::from_raw(pid), Signal::SIGCONT).is_ok() || kill(Pid::from_raw(pid), None).is_ok() } +/// Check if a PID is still running (macOS version using libc) +#[cfg(target_os = "macos")] +fn is_pid_running(pid: i32) -> bool { + // kill with signal 0 checks if process exists without sending a signal + unsafe { libc::kill(pid, 0) == 0 } +} + /// Check if lockfile is stale (PID no longer running) and remove it if so #[cfg(unix)] fn cleanup_stale_lockfile(lock_path: &std::path::Path) -> bool { diff --git a/src/error.rs b/src/error.rs index fb500fe6..e34f5d8c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -133,6 +133,7 @@ pub enum OutputError { /// Result type alias using VoxtypeError pub type Result = std::result::Result; +#[cfg(target_os = "linux")] impl From for HotkeyError { fn from(e: evdev::Error) -> Self { HotkeyError::Evdev(e.to_string()) diff --git a/src/hotkey_macos.rs b/src/hotkey_macos.rs index 3a82c1f6..a305f7d2 100644 --- a/src/hotkey_macos.rs +++ b/src/hotkey_macos.rs @@ -16,9 +16,10 @@ use std::sync::Mutex; use tokio::sync::mpsc; /// Hotkey events that can be sent from the listener -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum HotkeyEvent { - Pressed, + /// The hotkey was pressed (model_override not supported on macOS, always None) + Pressed { model_override: Option }, Released, Cancel, } @@ -92,7 +93,7 @@ impl HotkeyListener for RdevHotkeyListener { let mut last = last_press_clone.lock().unwrap(); if last.elapsed() > Duration::from_millis(debounce_ms) { *last = Instant::now(); - let _ = tx_clone.blocking_send(HotkeyEvent::Pressed); + let _ = tx_clone.blocking_send(HotkeyEvent::Pressed { model_override: None }); } } else if Some(key) == cancel_key { let _ = tx_clone.blocking_send(HotkeyEvent::Cancel); diff --git a/src/main.rs b/src/main.rs index 1bc9c24a..0889cac9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -388,12 +388,10 @@ async fn check_for_updates() -> anyhow::Result<()> { /// Send a record command to the running daemon via Unix signals or file triggers fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow::Result<()> { - use nix::sys::signal::{kill, Signal}; - use nix::unistd::Pid; use voxtype::OutputModeOverride; - // Read PID from the pid file - let pid_file = config::Config::runtime_dir().join("pid"); + // Read PID from the lock file (daemon writes PID to voxtype.lock) + let pid_file = config::Config::runtime_dir().join("voxtype.lock"); if !pid_file.exists() { eprintln!("Error: Voxtype daemon is not running."); @@ -409,8 +407,8 @@ fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow: .parse() .map_err(|e| anyhow::anyhow!("Invalid PID in file: {}", e))?; - // Check if the process is actually running - if kill(Pid::from_raw(pid), None).is_err() { + // Check if the process is actually running (signal 0 = check existence) + if unsafe { libc::kill(pid, 0) } != 0 { // Process doesn't exist, clean up stale PID file let _ = std::fs::remove_file(&pid_file); eprintln!("Error: Voxtype daemon is not running (stale PID file removed)."); @@ -479,9 +477,9 @@ fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow: } // For toggle, we need to read current state to decide which signal to send - let signal = match &action { - RecordAction::Start { .. } => Signal::SIGUSR1, - RecordAction::Stop { .. } => Signal::SIGUSR2, + let signal: libc::c_int = match &action { + RecordAction::Start { .. } => libc::SIGUSR1, + RecordAction::Stop { .. } => libc::SIGUSR2, RecordAction::Toggle { .. } => { // Read current state to determine action let state_file = match config.resolve_state_file() { @@ -503,16 +501,21 @@ fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow: std::fs::read_to_string(&state_file).unwrap_or_else(|_| "idle".to_string()); if current_state.trim() == "recording" { - Signal::SIGUSR2 // Stop + libc::SIGUSR2 // Stop } else { - Signal::SIGUSR1 // Start + libc::SIGUSR1 // Start } } RecordAction::Cancel => unreachable!(), // Handled above }; - kill(Pid::from_raw(pid), signal) - .map_err(|e| anyhow::anyhow!("Failed to send signal to daemon: {}", e))?; + let result = unsafe { libc::kill(pid, signal) }; + if result != 0 { + return Err(anyhow::anyhow!( + "Failed to send signal to daemon: {}", + std::io::Error::last_os_error() + )); + } Ok(()) } diff --git a/src/menubar.rs b/src/menubar.rs index f3d3d456..fa10afcb 100644 --- a/src/menubar.rs +++ b/src/menubar.rs @@ -1,9 +1,9 @@ //! macOS menu bar integration //! //! Provides a system tray icon showing voxtype status with a context menu -//! for controlling recording. +//! for controlling recording and configuring settings. -use crate::config::Config; +use crate::config::{ActivationMode, Config, OutputMode, TranscriptionEngine}; use pidlock::Pidlock; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -11,7 +11,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use tao::event_loop::{ControlFlow, EventLoopBuilder}; use tray_icon::{ - menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem}, + menu::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}, TrayIconBuilder, }; @@ -54,9 +54,40 @@ impl VoxtypeState { } /// Menu item IDs -const MENU_TOGGLE: &str = "toggle"; -const MENU_CANCEL: &str = "cancel"; -const MENU_QUIT: &str = "quit"; +mod menu_ids { + // Recording controls + pub const TOGGLE: &str = "toggle"; + pub const CANCEL: &str = "cancel"; + + // Engine selection + pub const ENGINE_PARAKEET: &str = "engine_parakeet"; + pub const ENGINE_WHISPER: &str = "engine_whisper"; + + // Hotkey mode + pub const HOTKEY_PTT: &str = "hotkey_ptt"; + pub const HOTKEY_TOGGLE: &str = "hotkey_toggle"; + + // Output mode + pub const OUTPUT_TYPE: &str = "output_type"; + pub const OUTPUT_CLIPBOARD: &str = "output_clipboard"; + pub const OUTPUT_PASTE: &str = "output_paste"; + + // Model prefixes (actual ID will be model_) + pub const MODEL_PREFIX: &str = "model_"; + + // Utilities + pub const DOWNLOAD_MODEL: &str = "download_model"; + pub const OPEN_CONFIG: &str = "open_config"; + pub const VIEW_LOGS: &str = "view_logs"; + pub const RESTART_DAEMON: &str = "restart_daemon"; + + // Auto-start + pub const AUTOSTART_ENABLE: &str = "autostart_enable"; + pub const AUTOSTART_DISABLE: &str = "autostart_disable"; + + // Quit + pub const QUIT: &str = "quit"; +} /// Read state from file fn read_state_from_file(path: &PathBuf) -> VoxtypeState { @@ -65,20 +96,387 @@ fn read_state_from_file(path: &PathBuf) -> VoxtypeState { .unwrap_or(VoxtypeState::Stopped) } -/// Execute voxtype command -fn voxtype_cmd(cmd: &str) { - // Use the full path if available, otherwise hope it's in PATH - let voxtype_path = std::env::current_exe() +/// Get the voxtype binary path +fn get_voxtype_path() -> PathBuf { + std::env::current_exe() .ok() .and_then(|p| p.parent().map(|d| d.join("voxtype"))) .filter(|p| p.exists()) - .unwrap_or_else(|| PathBuf::from("voxtype")); + .unwrap_or_else(|| PathBuf::from("voxtype")) +} + +/// Execute voxtype command +fn voxtype_cmd(args: &[&str]) { + let voxtype_path = get_voxtype_path(); + let _ = std::process::Command::new(voxtype_path).args(args).spawn(); +} + +/// Execute voxtype command and wait for completion +fn voxtype_cmd_wait(args: &[&str]) -> bool { + let voxtype_path = get_voxtype_path(); + std::process::Command::new(voxtype_path) + .args(args) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +/// Open a file or URL with the default application +fn open_path(path: &str) { + let _ = std::process::Command::new("open").arg(path).spawn(); +} + +/// Check if LaunchAgent is installed +fn is_autostart_enabled() -> bool { + let home = dirs::home_dir().unwrap_or_default(); + let plist = home.join("Library/LaunchAgents/io.voxtype.daemon.plist"); + plist.exists() +} + +/// Get list of downloaded models (both Whisper and Parakeet) +fn get_downloaded_models() -> Vec<(String, bool)> { + let mut models = Vec::new(); + let models_dir = Config::models_dir(); + + if let Ok(entries) = std::fs::read_dir(&models_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + + // Check for Whisper models (ggml-*.bin files) + if name.starts_with("ggml-") && name.ends_with(".bin") { + // Extract model name from filename (e.g., "ggml-base.en.bin" -> "base.en") + let model_name = name + .strip_prefix("ggml-") + .and_then(|s| s.strip_suffix(".bin")) + .unwrap_or(&name) + .to_string(); + models.push((model_name, false)); // false = Whisper + } + + // Check for Parakeet models (directories with encoder-model.onnx) + if path.is_dir() && name.contains("parakeet") { + if path.join("encoder-model.onnx").exists() { + models.push((name, true)); // true = Parakeet + } + } + } + } + + models.sort(); + models +} + +/// Update config file with new engine +fn set_engine(engine: TranscriptionEngine) -> bool { + let config_path = match Config::default_path() { + Some(p) => p, + None => return false, + }; + + let content = std::fs::read_to_string(&config_path).unwrap_or_default(); + + let engine_str = match engine { + TranscriptionEngine::Parakeet => "parakeet", + TranscriptionEngine::Whisper => "whisper", + }; + + // Check if engine line exists + let new_content = if content.contains("engine =") { + // Replace existing engine line + let re = regex::Regex::new(r#"engine\s*=\s*"[^"]*""#).unwrap(); + re.replace(&content, format!(r#"engine = "{}""#, engine_str)) + .to_string() + } else { + // Add engine line at the beginning + format!("engine = \"{}\"\n{}", engine_str, content) + }; + + std::fs::write(&config_path, new_content).is_ok() +} + +/// Update config file with new output mode +fn set_output_mode(mode: OutputMode) -> bool { + let config_path = match Config::default_path() { + Some(p) => p, + None => return false, + }; + + let content = std::fs::read_to_string(&config_path).unwrap_or_default(); + + let mode_str = match mode { + OutputMode::Type => "type", + OutputMode::Clipboard => "clipboard", + OutputMode::Paste => "paste", + OutputMode::File => "file", + }; + + // Check if [output] section exists with mode + let new_content = if content.contains("[output]") { + // Check if mode line exists under [output] + if let Some(output_start) = content.find("[output]") { + let after_output = &content[output_start..]; + if after_output.contains("mode =") { + // Replace existing mode line + let re = regex::Regex::new(r#"(\[output\][^\[]*?)mode\s*=\s*"[^"]*""#).unwrap(); + re.replace(&content, format!(r#"$1mode = "{}""#, mode_str)) + .to_string() + } else { + // Add mode line after [output] + content.replace("[output]", &format!("[output]\nmode = \"{}\"", mode_str)) + } + } else { + content.clone() + } + } else { + // Add [output] section + format!("{}\n[output]\nmode = \"{}\"\n", content, mode_str) + }; + + std::fs::write(&config_path, new_content).is_ok() +} + +/// Update config file with new hotkey mode +fn set_hotkey_mode(mode: ActivationMode) -> bool { + let config_path = match Config::default_path() { + Some(p) => p, + None => return false, + }; + + let content = std::fs::read_to_string(&config_path).unwrap_or_default(); + let mode_str = match mode { + ActivationMode::PushToTalk => "push_to_talk", + ActivationMode::Toggle => "toggle", + }; + + // Check if [hotkey] section exists + let new_content = if content.contains("[hotkey]") { + if content.contains("mode =") { + // Replace existing mode line + let re = regex::Regex::new(r#"mode\s*=\s*"[^"]*""#).unwrap(); + re.replace(&content, format!(r#"mode = "{}""#, mode_str)) + .to_string() + } else { + // Add mode line after [hotkey] + content.replace("[hotkey]", &format!("[hotkey]\nmode = \"{}\"", mode_str)) + } + } else { + // Add [hotkey] section + format!("{}\n[hotkey]\nmode = \"{}\"\n", content, mode_str) + }; + + std::fs::write(&config_path, new_content).is_ok() +} + +/// Update config to use a specific model +fn set_model(model_name: &str, is_parakeet: bool) -> bool { + if is_parakeet { + voxtype_cmd_wait(&["setup", "parakeet", "--set", model_name]) + } else { + voxtype_cmd_wait(&["setup", "model", "--set", model_name]) + } +} + +/// Restart the daemon +fn restart_daemon() { + // Try launchctl first + let _ = std::process::Command::new("launchctl") + .args(["kickstart", "-k", "gui/$(id -u)/io.voxtype.daemon"]) + .status(); + + // Fallback: kill and restart + let _ = std::process::Command::new("pkill") + .args(["-f", "voxtype daemon"]) + .status(); + + std::thread::sleep(Duration::from_millis(500)); + + voxtype_cmd(&["daemon"]); +} - let _ = std::process::Command::new(voxtype_path) - .args(["record", cmd]) +/// Show notification +fn notify(title: &str, message: &str) { + let _ = std::process::Command::new("osascript") + .args([ + "-e", + &format!( + "display notification \"{}\" with title \"{}\"", + message, title + ), + ]) .spawn(); } +/// Build the settings submenus +/// Returns (menu, status_item) so status can be updated later +fn build_menu(config: &Config) -> (Menu, MenuItem) { + let menu = Menu::new(); + + // Recording controls + let toggle_item = MenuItem::with_id(menu_ids::TOGGLE, "Toggle Recording", true, None); + let cancel_item = MenuItem::with_id(menu_ids::CANCEL, "Cancel Recording", true, None); + + menu.append(&toggle_item).unwrap(); + menu.append(&cancel_item).unwrap(); + menu.append(&PredefinedMenuItem::separator()).unwrap(); + + // Engine submenu + let engine_menu = Submenu::new("Engine", true); + let is_parakeet = config.engine == TranscriptionEngine::Parakeet; + + #[cfg(feature = "parakeet")] + { + let parakeet_item = CheckMenuItem::with_id( + menu_ids::ENGINE_PARAKEET, + "🦜 Parakeet (Fast)", + true, + is_parakeet, + None, + ); + engine_menu.append(¶keet_item).unwrap(); + } + + let whisper_item = CheckMenuItem::with_id( + menu_ids::ENGINE_WHISPER, + "🗣️ Whisper", + true, + !is_parakeet, + None, + ); + engine_menu.append(&whisper_item).unwrap(); + menu.append(&engine_menu).unwrap(); + + // Model submenu + let model_menu = Submenu::new("Model", true); + let downloaded_models = get_downloaded_models(); + let current_model = if is_parakeet { + config + .parakeet + .as_ref() + .map(|p| p.model.clone()) + .unwrap_or_default() + } else { + config.whisper.model.clone() + }; + + if downloaded_models.is_empty() { + let no_models = MenuItem::new("No models downloaded", false, None); + model_menu.append(&no_models).unwrap(); + } else { + for (model_name, model_is_parakeet) in &downloaded_models { + // Show models for the current engine + if *model_is_parakeet == is_parakeet { + let is_current = model_name == ¤t_model; + let display_name = if *model_is_parakeet { + format!("🦜 {}", model_name) + } else { + model_name.clone() + }; + let item = CheckMenuItem::with_id( + format!("{}{}", menu_ids::MODEL_PREFIX, model_name), + display_name, + true, + is_current, + None, + ); + model_menu.append(&item).unwrap(); + } + } + } + + model_menu.append(&PredefinedMenuItem::separator()).unwrap(); + let download_item = MenuItem::with_id(menu_ids::DOWNLOAD_MODEL, "Download Model...", true, None); + model_menu.append(&download_item).unwrap(); + menu.append(&model_menu).unwrap(); + + // Output mode submenu + let output_menu = Submenu::new("Output Mode", true); + let output_type = CheckMenuItem::with_id( + menu_ids::OUTPUT_TYPE, + "Type Text", + true, + config.output.mode == OutputMode::Type, + None, + ); + let output_clipboard = CheckMenuItem::with_id( + menu_ids::OUTPUT_CLIPBOARD, + "Copy to Clipboard", + true, + config.output.mode == OutputMode::Clipboard, + None, + ); + let output_paste = CheckMenuItem::with_id( + menu_ids::OUTPUT_PASTE, + "Clipboard + Paste", + true, + config.output.mode == OutputMode::Paste, + None, + ); + output_menu.append(&output_type).unwrap(); + output_menu.append(&output_clipboard).unwrap(); + output_menu.append(&output_paste).unwrap(); + menu.append(&output_menu).unwrap(); + + // Hotkey mode submenu + let hotkey_menu = Submenu::new("Hotkey Mode", true); + let is_toggle = config.hotkey.mode == ActivationMode::Toggle; + let ptt_item = CheckMenuItem::with_id( + menu_ids::HOTKEY_PTT, + "Push-to-Talk (hold)", + true, + !is_toggle, + None, + ); + let toggle_item = CheckMenuItem::with_id( + menu_ids::HOTKEY_TOGGLE, + "Toggle (press to start/stop)", + true, + is_toggle, + None, + ); + hotkey_menu.append(&ptt_item).unwrap(); + hotkey_menu.append(&toggle_item).unwrap(); + menu.append(&hotkey_menu).unwrap(); + + // Auto-start submenu + let autostart_menu = Submenu::new("Auto-start", true); + let autostart_enabled = is_autostart_enabled(); + let enable_item = CheckMenuItem::with_id( + menu_ids::AUTOSTART_ENABLE, + "Start at Login", + true, + autostart_enabled, + None, + ); + autostart_menu.append(&enable_item).unwrap(); + menu.append(&autostart_menu).unwrap(); + + menu.append(&PredefinedMenuItem::separator()).unwrap(); + + // Status (disabled, just for display) + let status_item = MenuItem::new("Status: Checking...", false, None); + menu.append(&status_item).unwrap(); + + menu.append(&PredefinedMenuItem::separator()).unwrap(); + + // Utilities + let config_item = MenuItem::with_id(menu_ids::OPEN_CONFIG, "Edit Config File...", true, None); + let logs_item = MenuItem::with_id(menu_ids::VIEW_LOGS, "View Logs", true, None); + let restart_item = MenuItem::with_id(menu_ids::RESTART_DAEMON, "Restart Daemon", true, None); + + menu.append(&config_item).unwrap(); + menu.append(&logs_item).unwrap(); + menu.append(&restart_item).unwrap(); + + menu.append(&PredefinedMenuItem::separator()).unwrap(); + + // Quit + let quit_item = MenuItem::with_id(menu_ids::QUIT, "Quit Menu Bar", true, None); + menu.append(&quit_item).unwrap(); + + (menu, status_item) +} + /// Run the menu bar application /// This should be called from the main thread /// Note: This function never returns (runs the macOS event loop) @@ -107,24 +505,18 @@ pub fn run(state_file: PathBuf) -> ! { println!("Start it with: voxtype daemon\n"); } - // Create menu items - let toggle_item = MenuItem::with_id(MENU_TOGGLE, "Toggle Recording", true, None); - let cancel_item = MenuItem::with_id(MENU_CANCEL, "Cancel Recording", true, None); - let status_item = MenuItem::new("Status: Checking...", false, None); - let quit_item = MenuItem::with_id(MENU_QUIT, "Quit Menu Bar", true, None); + // Load config + let config = crate::config::load_config(None).unwrap_or_default(); - // Create menu - let menu = Menu::new(); - menu.append(&toggle_item).expect("Failed to append toggle item"); - menu.append(&cancel_item).expect("Failed to append cancel item"); - menu.append(&PredefinedMenuItem::separator()).expect("Failed to append separator"); - menu.append(&status_item).expect("Failed to append status item"); - menu.append(&PredefinedMenuItem::separator()).expect("Failed to append separator"); - menu.append(&quit_item).expect("Failed to append quit item"); + // Build menu (returns menu and status item for updates) + let (menu, status_item) = build_menu(&config); // Get initial state let initial_state = read_state_from_file(&state_file); + // Update status item with initial state + let _ = status_item.set_text(initial_state.status_text()); + // Create tray icon let tray = TrayIconBuilder::new() .with_tooltip("Voxtype") @@ -133,9 +525,6 @@ pub fn run(state_file: PathBuf) -> ! { .build() .expect("Failed to create tray icon"); - // Update initial status - let _ = status_item.set_text(initial_state.status_text()); - println!("Menu bar is running. Look for the icon in your menu bar."); println!("Press Ctrl+C to stop.\n"); @@ -157,17 +546,115 @@ pub fn run(state_file: PathBuf) -> ! { // Check for menu events (non-blocking) if let Ok(event) = menu_channel.try_recv() { - match event.id().0.as_str() { - MENU_TOGGLE => { - voxtype_cmd("toggle"); + let id = event.id().0.as_str(); + + match id { + // Recording controls + menu_ids::TOGGLE => { + voxtype_cmd(&["record", "toggle"]); + } + menu_ids::CANCEL => { + voxtype_cmd(&["record", "cancel"]); + } + + // Engine selection + menu_ids::ENGINE_PARAKEET => { + if set_engine(TranscriptionEngine::Parakeet) { + notify("Voxtype", "Switched to Parakeet engine. Restart daemon to apply."); + } + } + menu_ids::ENGINE_WHISPER => { + if set_engine(TranscriptionEngine::Whisper) { + notify("Voxtype", "Switched to Whisper engine. Restart daemon to apply."); + } + } + + // Hotkey mode + menu_ids::HOTKEY_PTT => { + if set_hotkey_mode(ActivationMode::PushToTalk) { + notify("Voxtype", "Switched to push-to-talk mode. Restart daemon to apply."); + } + } + menu_ids::HOTKEY_TOGGLE => { + if set_hotkey_mode(ActivationMode::Toggle) { + notify("Voxtype", "Switched to toggle mode. Restart daemon to apply."); + } + } + + // Output mode + menu_ids::OUTPUT_TYPE => { + if set_output_mode(OutputMode::Type) { + notify("Voxtype", "Output mode: Type text"); + } } - MENU_CANCEL => { - voxtype_cmd("cancel"); + menu_ids::OUTPUT_CLIPBOARD => { + if set_output_mode(OutputMode::Clipboard) { + notify("Voxtype", "Output mode: Copy to clipboard"); + } } - MENU_QUIT => { + menu_ids::OUTPUT_PASTE => { + if set_output_mode(OutputMode::Paste) { + notify("Voxtype", "Output mode: Clipboard + Paste"); + } + } + + // Auto-start + menu_ids::AUTOSTART_ENABLE => { + if is_autostart_enabled() { + // Disable + if voxtype_cmd_wait(&["setup", "launchd", "--uninstall"]) { + notify("Voxtype", "Auto-start disabled"); + } + } else { + // Enable + if voxtype_cmd_wait(&["setup", "launchd"]) { + notify("Voxtype", "Auto-start enabled"); + } + } + } + + // Utilities + menu_ids::DOWNLOAD_MODEL => { + // Open terminal with model download command + let voxtype_path = get_voxtype_path(); + let script = format!( + "tell application \"Terminal\" to do script \"{}\" & \" setup model\"", + voxtype_path.display() + ); + let _ = std::process::Command::new("osascript") + .args(["-e", &script]) + .spawn(); + } + menu_ids::OPEN_CONFIG => { + if let Some(config_path) = Config::default_path() { + open_path(config_path.to_str().unwrap_or("")); + } + } + menu_ids::VIEW_LOGS => { + let home = dirs::home_dir().unwrap_or_default(); + let log_path = home.join("Library/Logs/voxtype"); + open_path(log_path.to_str().unwrap_or("")); + } + menu_ids::RESTART_DAEMON => { + notify("Voxtype", "Restarting daemon..."); + restart_daemon(); + } + + // Quit + menu_ids::QUIT => { running.store(false, Ordering::SeqCst); *control_flow = ControlFlow::Exit; } + + // Model selection (dynamic IDs) + _ if id.starts_with(menu_ids::MODEL_PREFIX) => { + let model_name = id.strip_prefix(menu_ids::MODEL_PREFIX).unwrap_or(""); + let is_parakeet = model_name.contains("parakeet"); + if set_model(model_name, is_parakeet) { + notify("Voxtype", &format!("Switched to model: {}", model_name)); + } + } + _ => {} } } @@ -177,12 +664,9 @@ pub fn run(state_file: PathBuf) -> ! { let new_state = read_state_from_file(&state_file); if new_state != last_state { - // Update icon + // Update icon and status text let _ = tray.set_title(Some(new_state.icon())); - - // Update status text let _ = status_item.set_text(new_state.status_text()); - last_state = new_state; } diff --git a/src/notification.rs b/src/notification.rs index b4657347..84b03df8 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -3,9 +3,11 @@ //! Provides a unified interface for sending desktop notifications on //! different platforms: //! - Linux: Uses notify-send (libnotify) -//! - macOS: Uses osascript (AppleScript) +//! - macOS: Uses native UserNotifications framework (appears under "Voxtype" in settings) use std::process::Stdio; + +#[cfg(target_os = "linux")] use tokio::process::Command; /// Send a desktop notification with the given title and body. @@ -17,7 +19,7 @@ pub async fn send(title: &str, body: &str) { send_linux(title, body).await; #[cfg(target_os = "macos")] - send_macos(title, body).await; + send_macos_native(title, body); #[cfg(not(any(target_os = "linux", target_os = "macos")))] { @@ -46,53 +48,23 @@ async fn send_linux(title: &str, body: &str) { } } -/// Send a notification on macOS -/// Prefers terminal-notifier (supports custom icons) with fallback to osascript -#[cfg(target_os = "macos")] -async fn send_macos(title: &str, body: &str) { - // Try terminal-notifier first (supports custom icons) - if send_macos_terminal_notifier(title, body).await { - return; - } - - // Fallback to osascript - send_macos_osascript(title, body).await; -} - -/// Send notification via terminal-notifier (supports custom icons) +/// Send a native macOS notification using UserNotifications framework +/// This makes notifications appear under "Voxtype" in System Settings > Notifications #[cfg(target_os = "macos")] -async fn send_macos_terminal_notifier(title: &str, body: &str) -> bool { - let mut args = vec![ - "-title".to_string(), - title.to_string(), - "-message".to_string(), - body.to_string(), - "-group".to_string(), - "voxtype".to_string(), - ]; - - // Add custom icon if available - if let Some(icon_path) = find_notification_icon() { - args.push("-appIcon".to_string()); - args.push(icon_path); - } - - let result = Command::new("terminal-notifier") - .args(&args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .await; - - match result { - Ok(status) => status.success(), - Err(_) => false, // terminal-notifier not available +fn send_macos_native(title: &str, body: &str) { + use mac_notification_sys::send_notification; + + // send_notification(title, subtitle, message, options) + if let Err(e) = send_notification(title, None, body, None) { + tracing::debug!("Failed to send native notification: {:?}", e); + // Fallback to osascript if native fails + send_macos_osascript_sync(title, body); } } -/// Send notification via osascript (fallback, no custom icon support) +/// Fallback notification via osascript (if native fails) #[cfg(target_os = "macos")] -async fn send_macos_osascript(title: &str, body: &str) { +fn send_macos_osascript_sync(title: &str, body: &str) { let escaped_title = title.replace('"', "\\\""); let escaped_body = body.replace('"', "\\\""); @@ -101,44 +73,11 @@ async fn send_macos_osascript(title: &str, body: &str) { escaped_body, escaped_title ); - let result = Command::new("osascript") + let _ = std::process::Command::new("osascript") .args(["-e", &script]) .stdout(Stdio::null()) .stderr(Stdio::null()) - .status() - .await; - - if let Err(e) = result { - tracing::debug!("Failed to send notification: {}", e); - } -} - -/// Find the notification icon path (returns file:// URL for terminal-notifier) -#[cfg(target_os = "macos")] -fn find_notification_icon() -> Option { - // Check common locations for the voxtype icon - let candidates = [ - // User-installed icon - dirs::data_dir().map(|d| d.join("voxtype/icon.png")), - // Config directory - dirs::config_dir().map(|d| d.join("voxtype/icon.png")), - // Homebrew installation - Some(std::path::PathBuf::from("/opt/homebrew/share/voxtype/icon.png")), - // System-wide - Some(std::path::PathBuf::from("/usr/local/share/voxtype/icon.png")), - ]; - - for candidate in candidates.into_iter().flatten() { - if candidate.exists() { - // Return as file:// URL to handle paths with spaces - let path_str = candidate.to_string_lossy(); - // URL-encode spaces and special characters - let encoded = path_str.replace(' ', "%20"); - return Some(format!("file://{}", encoded)); - } - } - - None + .spawn(); } /// Send a notification synchronously (blocking). @@ -149,7 +88,7 @@ pub fn send_sync(title: &str, body: &str) { send_linux_sync(title, body); #[cfg(target_os = "macos")] - send_macos_sync(title, body); + send_macos_native(title, body); #[cfg(not(any(target_os = "linux", target_os = "macos")))] { @@ -172,48 +111,8 @@ fn send_linux_sync(title: &str, body: &str) { .spawn(); } -/// Send a notification on macOS (synchronous) -#[cfg(target_os = "macos")] -fn send_macos_sync(title: &str, body: &str) { - // Try terminal-notifier first - let mut args = vec!["-title", title, "-message", body, "-group", "voxtype"]; - - let icon_path = find_notification_icon(); - if let Some(ref path) = icon_path { - args.push("-appIcon"); - args.push(path); - } - - let result = std::process::Command::new("terminal-notifier") - .args(&args) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - - if result.map(|s| s.success()).unwrap_or(false) { - return; - } - - // Fallback to osascript - let escaped_title = title.replace('"', "\\\""); - let escaped_body = body.replace('"', "\\\""); - - let script = format!( - r#"display notification "{}" with title "{}""#, - escaped_body, escaped_title - ); - - let _ = std::process::Command::new("osascript") - .args(["-e", &script]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); -} - #[cfg(test)] mod tests { - use super::*; - #[test] fn test_quote_escaping() { // Test that quotes are properly escaped for AppleScript diff --git a/src/output/mod.rs b/src/output/mod.rs index 9c95b30e..35e276b2 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -227,21 +227,24 @@ pub fn create_output_chain_with_override( { // macOS: Primary - CGEvent (native API, best performance) // driver_order not yet supported on macOS + let show_notification = config.notification.on_transcription; chain.push(Box::new(cgevent::CGEventOutput::new( config.type_delay_ms, pre_type_delay_ms, + show_notification, config.auto_submit, ))); // Fallback 1: osascript (AppleScript, works without CGEvent permissions) chain.push(Box::new(osascript::OsascriptOutput::new( + false, // notification already handled by primary config.auto_submit, pre_type_delay_ms, ))); // Fallback 2: pbcopy for clipboard if config.fallback_to_clipboard { - chain.push(Box::new(pbcopy::PbcopyOutput::new())); + chain.push(Box::new(pbcopy::PbcopyOutput::new(false))); } } @@ -292,7 +295,9 @@ pub fn create_output_chain_with_override( crate::config::OutputMode::Clipboard => { // Clipboard mode #[cfg(target_os = "macos")] - chain.push(Box::new(pbcopy::PbcopyOutput::new())); + chain.push(Box::new(pbcopy::PbcopyOutput::new( + config.notification.on_transcription, + ))); #[cfg(not(target_os = "macos"))] chain.push(Box::new(clipboard::ClipboardOutput::new( diff --git a/src/setup/macos.rs b/src/setup/macos.rs index d539006f..cb5f9919 100644 --- a/src/setup/macos.rs +++ b/src/setup/macos.rs @@ -1,18 +1,147 @@ //! macOS interactive setup wizard //! //! Provides a guided setup experience for macOS users, covering: -//! - Accessibility permission checks +//! - App bundle creation and code signing +//! - Microphone permission (required for audio capture) +//! - Accessibility permission (required for text injection) +//! - Notification permission (optional) //! - Hotkey configuration (native rdev or Hammerspoon) -//! - Menu bar setup //! - LaunchAgent auto-start //! - Model download use super::{print_failure, print_info, print_success, print_warning}; use std::io::{self, Write}; +use std::path::PathBuf; -/// Check if Terminal/iTerm has Accessibility permission +const APP_BUNDLE_PATH: &str = "/Applications/Voxtype.app"; +const BUNDLE_IDENTIFIER: &str = "io.voxtype"; + +/// Check if the app bundle exists and is properly signed +fn check_app_bundle() -> bool { + let app_path = PathBuf::from(APP_BUNDLE_PATH); + let binary_path = app_path.join("Contents/MacOS/voxtype"); + let info_plist = app_path.join("Contents/Info.plist"); + + app_path.exists() && binary_path.exists() && info_plist.exists() +} + +/// Create the app bundle with proper Info.plist for permissions +async fn create_app_bundle() -> anyhow::Result<()> { + let app_path = PathBuf::from(APP_BUNDLE_PATH); + let contents_path = app_path.join("Contents"); + let macos_path = contents_path.join("MacOS"); + let resources_path = contents_path.join("Resources"); + + // Create directory structure + std::fs::create_dir_all(&macos_path)?; + std::fs::create_dir_all(&resources_path)?; + + // Get current binary path + let current_exe = std::env::current_exe()?; + let binary_dest = macos_path.join("voxtype"); + + // Copy binary to app bundle + std::fs::copy(¤t_exe, &binary_dest)?; + + // Get version from Cargo + let version = env!("CARGO_PKG_VERSION"); + + // Create Info.plist with all required permission descriptions + let info_plist = format!(r#" + + + + CFBundleExecutable + voxtype + CFBundleIdentifier + {} + CFBundleName + Voxtype + CFBundleDisplayName + Voxtype + CFBundlePackageType + APPL + CFBundleShortVersionString + {} + CFBundleVersion + {} + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Voxtype needs microphone access to capture your voice for speech-to-text transcription. + NSAppleEventsUsageDescription + Voxtype needs to send keystrokes to type transcribed text into applications. + + +"#, BUNDLE_IDENTIFIER, version, version); + + std::fs::write(contents_path.join("Info.plist"), info_plist)?; + + // Copy icon if available + if let Some(data_dir) = directories::BaseDirs::new().map(|d| d.data_dir().join("voxtype")) { + let icon_src = data_dir.join("icon.png"); + if icon_src.exists() { + let _ = std::fs::copy(&icon_src, resources_path.join("icon.png")); + } + } + + // Sign the app bundle + let sign_result = tokio::process::Command::new("codesign") + .args(["--force", "--deep", "--sign", "-", APP_BUNDLE_PATH]) + .output() + .await; + + match sign_result { + Ok(output) if output.status.success() => Ok(()), + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Code signing failed: {}", stderr) + } + Err(e) => anyhow::bail!("Failed to run codesign: {}", e), + } +} + +/// Reset TCC permissions for Voxtype (forces re-prompt) +async fn reset_permissions() -> bool { + let mic_reset = tokio::process::Command::new("tccutil") + .args(["reset", "Microphone", BUNDLE_IDENTIFIER]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + + let acc_reset = tokio::process::Command::new("tccutil") + .args(["reset", "Accessibility", BUNDLE_IDENTIFIER]) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + + mic_reset || acc_reset +} + +/// Check if microphone permission is granted +/// This is tricky - we can't directly check, but we can try to access an audio device +async fn check_microphone_permission() -> bool { + // Use a simple AppleScript to check if we can access audio input + // This isn't perfect but gives a reasonable indication + let output = tokio::process::Command::new("osascript") + .args(["-e", "do shell script \"echo test\""]) + .output() + .await; + + // For now, we'll assume permission is needed and guide the user + // The real check happens when the daemon tries to capture audio + output.map(|o| o.status.success()).unwrap_or(false) +} + +/// Check if Accessibility permission is granted using AXIsProcessTrusted equivalent async fn check_accessibility_permission() -> bool { - // Try to use osascript to check if we can control System Events + // Try to use osascript to control System Events - this requires Accessibility let output = tokio::process::Command::new("osascript") .args(["-e", "tell application \"System Events\" to return name of first process"]) .output() @@ -24,6 +153,48 @@ async fn check_accessibility_permission() -> bool { } } +/// Open System Settings to a specific privacy pane +async fn open_privacy_settings(pane: &str) -> bool { + let url = format!("x-apple.systempreferences:com.apple.preference.security?Privacy_{}", pane); + + tokio::process::Command::new("open") + .arg(&url) + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Check if Voxtype is in the Accessibility list (even if disabled) +async fn is_in_accessibility_list() -> bool { + // Check the TCC database - this is a heuristic + let output = tokio::process::Command::new("sqlite3") + .args([ + "/Library/Application Support/com.apple.TCC/TCC.db", + &format!("SELECT client FROM access WHERE client='{}' AND service='kTCCServiceAccessibility'", BUNDLE_IDENTIFIER), + ]) + .output() + .await; + + // If we can't query (permission denied), check user database + if output.is_err() || !output.as_ref().unwrap().status.success() { + if let Some(home) = directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) { + let user_db = home.join("Library/Application Support/com.apple.TCC/TCC.db"); + let output = tokio::process::Command::new("sqlite3") + .args([ + user_db.to_str().unwrap_or(""), + &format!("SELECT client FROM access WHERE client='{}' AND service='kTCCServiceAccessibility'", BUNDLE_IDENTIFIER), + ]) + .output() + .await; + + return output.map(|o| !o.stdout.is_empty()).unwrap_or(false); + } + } + + output.map(|o| !o.stdout.is_empty()).unwrap_or(false) +} + /// Check if Hammerspoon is installed async fn check_hammerspoon() -> bool { std::path::Path::new("/Applications/Hammerspoon.app").exists() @@ -45,6 +216,29 @@ async fn check_terminal_notifier() -> bool { .unwrap_or(false) } +/// Check if system language is English +async fn is_system_language_english() -> bool { + let output = tokio::process::Command::new("defaults") + .args(["read", "NSGlobalDomain", "AppleLanguages"]) + .output() + .await; + + match output { + Ok(o) if o.status.success() => { + let languages = String::from_utf8_lossy(&o.stdout); + languages + .lines() + .find(|line| line.trim().starts_with('"')) + .map(|line| { + let trimmed = line.trim().trim_matches(|c| c == '"' || c == ','); + trimmed.starts_with("en") + }) + .unwrap_or(true) + } + _ => true, + } +} + /// Get user input with a default value fn prompt(message: &str, default: &str) -> String { print!("{} [{}]: ", message, default); @@ -79,6 +273,14 @@ fn prompt_yn(message: &str, default: bool) -> bool { } } +/// Wait for user to press Enter +fn wait_for_enter(message: &str) { + print!("{}", message); + io::stdout().flush().unwrap(); + let mut input = String::new(); + let _ = io::stdin().read_line(&mut input); +} + /// Print a section header fn section(title: &str) { println!("\n\x1b[1m{}\x1b[0m", title); @@ -97,7 +299,6 @@ fn check_notification_icon() -> bool { /// Install a default notification icon fn install_default_icon_file() -> anyhow::Result<()> { - // Create the data directory let data_dir = dirs::data_dir() .ok_or_else(|| anyhow::anyhow!("Could not find Application Support directory"))? .join("voxtype"); @@ -105,10 +306,6 @@ fn install_default_icon_file() -> anyhow::Result<()> { std::fs::create_dir_all(&data_dir)?; let icon_path = data_dir.join("icon.png"); - - // Create a simple microphone icon as PNG - // This is a 64x64 PNG with a microphone glyph - // Base64-encoded PNG data for a simple blue microphone icon let icon_data = include_bytes!("../../assets/icon.png"); std::fs::write(&icon_path, icon_data)?; @@ -116,110 +313,234 @@ fn install_default_icon_file() -> anyhow::Result<()> { Ok(()) } +/// Get the app bundle binary path +pub fn get_app_bundle_path() -> String { + format!("{}/Contents/MacOS/voxtype", APP_BUNDLE_PATH) +} + /// Run the macOS setup wizard pub async fn run() -> anyhow::Result<()> { println!("\x1b[1mVoxtype macOS Setup Wizard\x1b[0m"); println!("==========================\n"); - println!("This wizard will guide you through setting up Voxtype on macOS.\n"); + println!("This wizard will set up Voxtype as a native macOS app with proper permissions.\n"); + + // Step 1: Create App Bundle + section("Step 1: App Bundle"); + + let app_exists = check_app_bundle(); + if app_exists { + print_success("Voxtype.app already exists"); + let recreate = prompt_yn("Recreate app bundle? (recommended after updates)", true); + if recreate { + println!(" Creating app bundle..."); + match create_app_bundle().await { + Ok(_) => print_success("App bundle created and signed"), + Err(e) => { + print_failure(&format!("Failed to create app bundle: {}", e)); + println!(" You may need to run with sudo or manually create the bundle"); + return Err(e); + } + } + } + } else { + println!("Voxtype needs to be installed as an app bundle for proper macOS integration.\n"); + println!("This will:"); + println!(" - Create /Applications/Voxtype.app"); + println!(" - Enable proper permission prompts"); + println!(" - Allow adding to Login Items\n"); + + let create = prompt_yn("Create app bundle?", true); + if create { + println!(" Creating app bundle..."); + match create_app_bundle().await { + Ok(_) => print_success("App bundle created and signed"), + Err(e) => { + print_failure(&format!("Failed to create app bundle: {}", e)); + println!(" You may need to run with sudo or manually create the bundle"); + return Err(e); + } + } + } else { + print_warning("Skipping app bundle creation"); + println!(" Note: Without the app bundle, permissions may not work correctly"); + } + } - // Step 1: Check system requirements - section("Step 1: System Requirements"); + // Step 2: Microphone Permission + section("Step 2: Microphone Permission"); - // Check macOS version - let macos_version = tokio::process::Command::new("sw_vers") - .args(["-productVersion"]) - .output() - .await - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|_| "Unknown".to_string()); - print_success(&format!("macOS version: {}", macos_version)); + println!("Voxtype needs microphone access to capture your voice.\n"); + println!("We'll open System Settings and launch Voxtype to trigger the permission prompt.\n"); + + let setup_mic = prompt_yn("Set up microphone permission now?", true); + if setup_mic { + // Reset permissions to ensure a fresh prompt + let _ = reset_permissions().await; + + // Open System Settings to Microphone + print_info("Opening System Settings > Privacy & Security > Microphone..."); + open_privacy_settings("Microphone").await; + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + // Launch the app to trigger the permission prompt + print_info("Launching Voxtype to trigger permission prompt..."); + let app_binary = get_app_bundle_path(); + let _ = tokio::process::Command::new(&app_binary) + .arg("daemon") + .spawn(); + + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + println!(); + println!(" \x1b[1mAction required:\x1b[0m"); + println!(" 1. If a permission dialog appears, click 'OK' to allow microphone access"); + println!(" 2. If no dialog appears, find 'Voxtype' in the list and toggle it ON"); + println!(" 3. If Voxtype isn't in the list, press the hotkey once to trigger the prompt"); + println!(); + + wait_for_enter("Press Enter when microphone permission is granted..."); + + // Kill the test daemon + let _ = tokio::process::Command::new("pkill") + .args(["-f", "voxtype"]) + .output() + .await; + + print_success("Microphone permission configured"); + } + + // Step 3: Input Monitoring Permission (for hotkey capture) + section("Step 3: Input Monitoring Permission"); + + println!("Voxtype needs Input Monitoring permission to capture global hotkeys.\n"); + + let setup_input = prompt_yn("Set up Input Monitoring permission now?", true); + if setup_input { + print_info("Opening System Settings > Privacy & Security > Input Monitoring..."); + open_privacy_settings("ListenEvent").await; + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + println!(); + println!(" \x1b[1mAction required:\x1b[0m"); + println!(" 1. Click the '+' button"); + println!(" 2. Navigate to /Applications"); + println!(" 3. Select 'Voxtype.app'"); + println!(" 4. Ensure the toggle is ON"); + println!(); + + wait_for_enter("Press Enter when Input Monitoring permission is granted..."); + print_success("Input Monitoring permission configured"); + } + + // Step 4: Accessibility Permission (for text injection) + section("Step 4: Accessibility Permission"); + + println!("Voxtype needs Accessibility permission to type transcribed text.\n"); - // Check accessibility permission - print!(" Checking Accessibility permission... "); - io::stdout().flush().unwrap(); let has_accessibility = check_accessibility_permission().await; - println!(); if has_accessibility { - print_success("Accessibility permission granted"); + print_success("Accessibility permission already granted"); } else { - print_warning("Accessibility permission not granted"); - println!(); - println!(" To enable typing output, grant Accessibility permission to your terminal:"); - println!(" 1. Open System Settings → Privacy & Security → Accessibility"); - println!(" 2. Add your terminal app (Terminal, iTerm2, Alacritty, etc.)"); - println!(" 3. Restart your terminal after granting permission"); - println!(); - println!(" Alternatively, use Hammerspoon for hotkey support (no terminal permission needed)"); + let setup_acc = prompt_yn("Set up Accessibility permission now?", true); + if setup_acc { + print_info("Opening System Settings > Privacy & Security > Accessibility..."); + open_privacy_settings("Accessibility").await; + + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + println!(); + println!(" \x1b[1mAction required:\x1b[0m"); + println!(" 1. Click the '+' button"); + println!(" 2. Navigate to /Applications"); + println!(" 3. Select 'Voxtype.app'"); + println!(" 4. Ensure the toggle is ON"); + println!(); + + wait_for_enter("Press Enter when Accessibility permission is granted..."); + + // Verify permission + let has_acc_now = check_accessibility_permission().await; + if has_acc_now { + print_success("Accessibility permission granted"); + } else { + print_warning("Accessibility permission may not be fully configured"); + println!(" Text typing will fall back to clipboard if needed"); + } + } } - // Check terminal-notifier + // Step 5: Notification Permission (Optional) + section("Step 5: Notifications (Optional)"); + let has_notifier = check_terminal_notifier().await; + + println!("Voxtype can show notifications when transcription completes.\n"); + if has_notifier { print_success("terminal-notifier installed (enhanced notifications)"); } else { - print_info("terminal-notifier not installed (optional, for better notifications)"); + print_info("terminal-notifier not installed"); println!(" Install with: brew install terminal-notifier"); } - // Step 2: Hotkey configuration - section("Step 2: Hotkey Configuration"); + let setup_notifications = prompt_yn("Configure notification permission?", false); + if setup_notifications { + print_info("Opening System Settings > Notifications..."); + let _ = tokio::process::Command::new("open") + .arg("x-apple.systempreferences:com.apple.preference.notifications") + .output() + .await; + + println!(); + println!(" Find 'Voxtype' in the list and configure notification settings."); + println!(); + + wait_for_enter("Press Enter when done..."); + } + + // Step 6: Hotkey Configuration + section("Step 6: Hotkey Configuration"); let has_hammerspoon = check_hammerspoon().await; println!("Voxtype supports two methods for global hotkey capture:\n"); - println!(" 1. Native (rdev) - Built-in, requires Accessibility permission for terminal"); - println!(" 2. Hammerspoon - External app, doesn't require terminal permission\n"); + println!(" 1. Native (rdev) - Built-in, requires Accessibility permission"); + println!(" 2. Hammerspoon - External app, more reliable on some systems\n"); if has_hammerspoon { print_success("Hammerspoon is installed"); } else { - print_info("Hammerspoon is not installed"); + print_info("Hammerspoon is not installed (optional)"); println!(" Install with: brew install --cask hammerspoon"); } let use_hammerspoon = if has_hammerspoon { println!(); - prompt_yn("Use Hammerspoon for hotkey support?", !has_accessibility) + prompt_yn("Use Hammerspoon for hotkey support?", false) } else { false }; let hotkey = prompt("\nHotkey to use", "rightalt"); - let toggle_mode = prompt_yn("Use toggle mode? (press to start/stop instead of hold to record)", false); + let toggle_mode = prompt_yn("Use toggle mode? (press to start/stop instead of hold)", false); if use_hammerspoon { println!(); println!("Setting up Hammerspoon integration..."); - // Install the Hammerspoon module if let Err(e) = super::hammerspoon::run(true, false, &hotkey, toggle_mode).await { print_warning(&format!("Could not set up Hammerspoon: {}", e)); } } else { print_success(&format!("Configured native hotkey: {}", hotkey)); print_info(&format!("Mode: {}", if toggle_mode { "toggle" } else { "push-to-talk" })); - - if !has_accessibility { - println!(); - print_warning("Remember to grant Accessibility permission to your terminal!"); - } - } - - // Step 3: Menu bar - section("Step 3: Menu Bar Integration"); - - println!("The menu bar helper shows recording status and provides quick controls.\n"); - - let setup_menubar = prompt_yn("Set up menu bar helper?", true); - - if setup_menubar { - print_success("Menu bar helper will be available via: voxtype menubar"); - print_info("You can add it to LaunchAgent for auto-start (next step)"); } - // Step 4: Auto-start - section("Step 4: Auto-start Configuration"); + // Step 7: Auto-start + section("Step 7: Auto-start Configuration"); println!("Voxtype can start automatically when you log in.\n"); @@ -229,13 +550,16 @@ pub async fn run() -> anyhow::Result<()> { println!(); println!("Installing LaunchAgent..."); - if let Err(e) = super::launchd::install().await { + // First, update launchd to use the app bundle path + if let Err(e) = install_launchd_with_app_bundle().await { print_warning(&format!("Could not install LaunchAgent: {}", e)); + } else { + print_success("LaunchAgent installed"); } } - // Step 5: Notification icon - section("Step 5: Notification Icon (Optional)"); + // Step 8: Notification icon + section("Step 8: Notification Icon (Optional)"); if has_notifier { println!("terminal-notifier supports custom notification icons.\n"); @@ -245,11 +569,6 @@ pub async fn run() -> anyhow::Result<()> { print_success("Custom notification icon is installed"); } else { print_info("No custom notification icon found"); - println!(); - println!(" To add a custom icon, place a PNG file at one of:"); - println!(" - ~/Library/Application Support/voxtype/icon.png"); - println!(" - ~/.config/voxtype/icon.png"); - println!(); let install_default_icon = prompt_yn("Install a default microphone icon?", true); if install_default_icon { @@ -264,89 +583,270 @@ pub async fn run() -> anyhow::Result<()> { print_info("Install terminal-notifier to enable custom notification icons"); } - // Step 6: Model download - section("Step 6: Whisper Model"); + // Step 9: Model download + section("Step 9: Speech Recognition Model"); - // Load config to get current model let config = crate::config::load_config(None).unwrap_or_default(); - let current_model = &config.whisper.model; - - println!("Voxtype uses Whisper for speech recognition.\n"); - println!("Available models (from fastest to most accurate):"); - println!(" tiny.en - Fastest, English only (~75 MB)"); - println!(" base.en - Fast, English only (~145 MB)"); - println!(" small.en - Balanced, English only (~500 MB)"); - println!(" medium.en - Accurate, English only (~1.5 GB)"); - println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); - println!(); - println!("Current model: {}", current_model); + let models_dir = crate::Config::models_dir(); - let model = prompt("\nModel to use", current_model); + #[cfg(feature = "parakeet")] + let has_parakeet = true; + #[cfg(not(feature = "parakeet"))] + let has_parakeet = false; - // Check if model is downloaded - let models_dir = crate::Config::models_dir(); - let model_filename = crate::transcribe::whisper::get_model_filename(&model); - let model_path = models_dir.join(&model_filename); + let is_english = is_system_language_english().await; - if model_path.exists() { - print_success(&format!("Model '{}' is already downloaded", model)); - } else { - let download = prompt_yn(&format!("Download model '{}'?", model), true); - if download { + let (use_parakeet, model) = if has_parakeet { + println!("Voxtype supports two speech recognition engines:\n"); + + if is_english { + println!(" 1. Parakeet (Recommended) - NVIDIA's FastConformer via CoreML"); + println!(" - ~8x faster than Whisper on Apple Silicon"); + println!(" - Optimized for macOS Neural Engine"); + println!(" - English only"); + println!(); + println!(" 2. Whisper - OpenAI's Whisper via whisper.cpp"); + println!(" - Broader language support"); + println!(" - More model size options"); + } else { + println!(" 1. Whisper (Recommended) - OpenAI's Whisper via whisper.cpp"); + println!(" - Supports your system language"); + println!(" - Multiple model sizes available"); println!(); - println!("Downloading model... (this may take a while)"); - if let Err(e) = super::model::download_model(&model) { - print_failure(&format!("Download failed: {}", e)); + println!(" 2. Parakeet - NVIDIA's FastConformer via CoreML"); + println!(" - ~8x faster on Apple Silicon"); + println!(" - English only"); + print_warning("Your system language is not English. Parakeet only supports English."); + } + println!(); + + let use_parakeet = prompt_yn("Use Parakeet?", is_english); + + if use_parakeet { + println!(); + println!("Available Parakeet models:"); + println!(" parakeet-tdt-0.6b-v3 - Full precision (~1.2 GB)"); + println!(" parakeet-tdt-0.6b-v3-int8 - Quantized, faster (~670 MB)"); + println!(); + + let current = config.parakeet.as_ref() + .map(|p| p.model.as_str()) + .unwrap_or("parakeet-tdt-0.6b-v3-int8"); + let model = prompt("Model to use", current); + (true, model) + } else { + println!(); + println!("Available Whisper models (from fastest to most accurate):"); + if is_english { + println!(" tiny.en - Fastest, English only (~75 MB)"); + println!(" base.en - Fast, English only (~145 MB)"); + println!(" small.en - Balanced, English only (~500 MB)"); + println!(" medium.en - Accurate, English only (~1.5 GB)"); + println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); + } else { + println!(" tiny - Fastest, multilingual (~75 MB)"); + println!(" base - Fast, multilingual (~145 MB)"); + println!(" small - Balanced, multilingual (~500 MB)"); + println!(" medium - Accurate, multilingual (~1.5 GB)"); + println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); + } + println!(); + + let default_model = if is_english { + config.whisper.model.as_str() } else { - print_success("Model downloaded successfully"); + "large-v3-turbo" + }; + let model = prompt("Model to use", default_model); + (false, model) + } + } else { + println!("Voxtype uses Whisper for speech recognition.\n"); + println!("Available models (from fastest to most accurate):"); + if is_english { + println!(" tiny.en - Fastest, English only (~75 MB)"); + println!(" base.en - Fast, English only (~145 MB)"); + println!(" small.en - Balanced, English only (~500 MB)"); + println!(" medium.en - Accurate, English only (~1.5 GB)"); + println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); + } else { + println!(" tiny - Fastest, multilingual (~75 MB)"); + println!(" base - Fast, multilingual (~145 MB)"); + println!(" small - Balanced, multilingual (~500 MB)"); + println!(" medium - Accurate, multilingual (~1.5 GB)"); + println!(" large-v3-turbo - Most accurate, all languages (~1.6 GB)"); + } + println!(); + + let default_model = if is_english { + config.whisper.model.as_str() + } else { + "large-v3-turbo" + }; + let model = prompt("Model to use", default_model); + (false, model) + }; - // Update config to use the new model - if let Err(e) = super::model::set_model_config(&model) { - print_warning(&format!("Could not update config: {}", e)); + // Download and configure the selected model + if use_parakeet { + let model_path = models_dir.join(&model); + let model_valid = model_path.exists() + && super::model::validate_parakeet_model(&model_path).is_ok(); + + if model_valid { + print_success(&format!("Model '{}' is already downloaded", model)); + } else { + let download = prompt_yn(&format!("Download model '{}'?", model), true); + if download { + println!(); + println!("Downloading model... (this may take a while)"); + if let Err(e) = super::model::download_parakeet_model(&model) { + print_failure(&format!("Download failed: {}", e)); + } else { + print_success("Model downloaded successfully"); + } + } + } + + if let Err(e) = super::model::set_parakeet_config(&model) { + print_warning(&format!("Could not update config: {}", e)); + } else { + print_success("Config updated to use Parakeet engine"); + } + } else { + let model_filename = crate::transcribe::whisper::get_model_filename(&model); + let model_path = models_dir.join(&model_filename); + + if model_path.exists() { + print_success(&format!("Model '{}' is already downloaded", model)); + } else { + let download = prompt_yn(&format!("Download model '{}'?", model), true); + if download { + println!(); + println!("Downloading model... (this may take a while)"); + if let Err(e) = super::model::download_model(&model) { + print_failure(&format!("Download failed: {}", e)); + } else { + print_success("Model downloaded successfully"); + + if let Err(e) = super::model::set_model_config(&model) { + print_warning(&format!("Could not update config: {}", e)); + } } } } } + let engine_name = if use_parakeet { "Parakeet" } else { "Whisper" }; + // Summary section("Setup Complete!"); - println!("Your voxtype installation is ready. Here's a summary:\n"); + println!("Your Voxtype installation is ready. Here's a summary:\n"); + println!(" App bundle: /Applications/Voxtype.app"); if use_hammerspoon { println!(" Hotkey method: Hammerspoon"); - println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); } else { println!(" Hotkey method: Native (rdev)"); - println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); } + println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); + println!(" Engine: {}", engine_name); println!(" Model: {}", model); - println!(" Menu bar: {}", if setup_menubar { "enabled" } else { "disabled" }); println!(" Auto-start: {}", if setup_autostart { "enabled" } else { "disabled" }); - println!("\n\x1b[1mNext steps:\x1b[0m\n"); + println!("\n\x1b[1mStarting Voxtype...\x1b[0m\n"); - if !setup_autostart { - println!(" 1. Start the daemon: voxtype daemon"); + // Start the daemon + if setup_autostart { + let _ = tokio::process::Command::new("launchctl") + .args(["load", &format!("{}/Library/LaunchAgents/io.voxtype.daemon.plist", + dirs::home_dir().map(|h| h.to_string_lossy().to_string()).unwrap_or_default())]) + .output() + .await; + print_success("Daemon started via LaunchAgent"); } else { - println!(" 1. The daemon will start automatically (or run: voxtype daemon)"); + let app_binary = get_app_bundle_path(); + let _ = tokio::process::Command::new(&app_binary) + .arg("daemon") + .spawn(); + print_success("Daemon started"); } - if setup_menubar { - println!(" 2. Start the menu bar: voxtype menubar"); - } + println!(); + println!("Press {} to start recording!", hotkey); + println!(); + println!("Useful commands:"); + println!(" voxtype status - Check daemon status"); + println!(" voxtype status --follow - Watch status in real-time"); + println!(" voxtype record toggle - Toggle recording from CLI"); - if use_hammerspoon { - println!(" 3. Reload Hammerspoon config (click menu bar icon → Reload Config)"); - } + Ok(()) +} - println!(); - println!("Then just press {} to start recording!", hotkey); +/// Install LaunchAgent configured to use the app bundle +async fn install_launchd_with_app_bundle() -> anyhow::Result<()> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; + let launch_agents_dir = home.join("Library/LaunchAgents"); + let logs_dir = home.join("Library/Logs/voxtype"); - if !has_accessibility && !use_hammerspoon { - println!(); - print_warning("Don't forget to grant Accessibility permission to your terminal!"); - } + std::fs::create_dir_all(&launch_agents_dir)?; + std::fs::create_dir_all(&logs_dir)?; + + let app_binary = get_app_bundle_path(); + + let plist_content = format!(r#" + + + + Label + io.voxtype.daemon + + ProgramArguments + + {} + daemon + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + {}/stdout.log + + StandardErrorPath + {}/stderr.log + + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + + + ProcessType + Interactive + + Nice + -10 + + +"#, app_binary, logs_dir.display(), logs_dir.display()); + + let plist_path = launch_agents_dir.join("io.voxtype.daemon.plist"); + + // Unload existing if present + let _ = tokio::process::Command::new("launchctl") + .args(["unload", plist_path.to_str().unwrap_or("")]) + .output() + .await; + + std::fs::write(&plist_path, plist_content)?; + + print_success(&format!("Created: {}", plist_path.display())); + print_success(&format!("Logs: {}", logs_dir.display())); Ok(()) } From 55769df3704e4828896ffe271b0fb0284b54b333 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 18:12:43 -0500 Subject: [PATCH 03/33] Restore Linux-specific documentation removed during macOS merge - CONFIGURATION.md: Restore CLI backend docs (backend="cli", whisper_cli_path) - TROUBLESHOOTING.md: Restore X11, keyboard layout, and FFI crash sections - INSTALL.md: Restore Fedora ydotool system service notes --- docs/CONFIGURATION.md | 50 ++++++++++++- docs/INSTALL.md | 7 +- docs/TROUBLESHOOTING.md | 153 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 4 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c0b068d3..6ae95fc0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -360,18 +360,30 @@ Controls the Whisper speech-to-text engine. Selects the transcription backend. **Values:** -- `local` - Use whisper.cpp locally on your machine (default, fully offline) +- `local` - Use whisper.cpp locally via FFI bindings (default, fully offline) - `remote` - Send audio to a remote server for transcription +- `cli` - Use whisper-cli subprocess (fallback for systems where FFI crashes) > **Privacy Notice**: When using `remote` backend, audio is transmitted over the network. See [User Manual - Remote Whisper Servers](USER_MANUAL.md#remote-whisper-servers) for privacy considerations. -**Example:** +**When to use `cli` backend (Linux only):** +The `cli` backend is a workaround for systems where the whisper-rs FFI bindings crash due to C++ exceptions crossing the FFI boundary. This affects some systems with glibc 2.42+ (e.g., Ubuntu 25.10). If voxtype crashes during transcription, try the `cli` backend. + +Requires `whisper-cli` from [whisper.cpp](https://github.com/ggerganov/whisper.cpp). + +**Examples:** ```toml [whisper] backend = "remote" remote_endpoint = "http://192.168.1.100:8080" ``` +```toml +[whisper] +backend = "cli" +whisper_cli_path = "/usr/local/bin/whisper-cli" # Optional +``` + ### model **Type:** String @@ -826,6 +838,40 @@ remote_endpoint = "http://192.168.1.100:8080" remote_timeout_secs = 60 # 60 second timeout for long recordings ``` +### whisper_cli_path + +**Type:** String +**Default:** Auto-detected from PATH +**Required:** No +**Platform:** Linux only + +Path to the `whisper-cli` binary. Only used when `backend = "cli"`. + +If not specified, voxtype searches for `whisper-cli` or `whisper` in: +1. Your `$PATH` +2. Common system locations (`/usr/local/bin`, `/usr/bin`) +3. Current directory +4. `~/.local/bin` + +**Example:** +```toml +[whisper] +backend = "cli" +whisper_cli_path = "/opt/whisper.cpp/build/bin/whisper-cli" +``` + +**Installing whisper-cli:** + +Build from source at [github.com/ggerganov/whisper.cpp](https://github.com/ggerganov/whisper.cpp): + +```bash +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build +cmake --build build --config Release +sudo cp build/bin/whisper-cli /usr/local/bin/ +``` + --- ## [parakeet] diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 0af9444c..ec71665a 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -346,10 +346,12 @@ sudo pacman -S ydotool # Ubuntu: sudo apt install ydotool -# Enable and start the daemon +# Enable and start the daemon (Arch/Ubuntu) systemctl --user enable --now ydotool ``` +> **Note (Fedora):** Fedora's ydotool uses a system service that requires additional configuration. See [Troubleshooting - ydotool daemon not running](TROUBLESHOOTING.md#ydotool-daemon-not-running) for Fedora-specific setup. + **On KDE Plasma or GNOME (Wayland):** wtype does not work on these desktops because they don't support the virtual keyboard protocol. Install dotool (recommended) or use ydotool: For dotool (recommended, supports keyboard layouts): @@ -364,7 +366,8 @@ For ydotool: ```bash # Install ydotool (see commands above for your distro) # Then enable and start the daemon (required!) -systemctl --user enable --now ydotool +systemctl --user enable --now ydotool # Arch/Ubuntu +# For Fedora, see Troubleshooting guide for system service setup ``` Voxtype uses wtype on Wayland (no daemon needed), with dotool and ydotool as fallbacks, and clipboard as the last resort. On KDE/GNOME Wayland, wtype will fail and voxtype will use dotool or ydotool. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 19f85de7..1c30d49f 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -10,6 +10,8 @@ Solutions to common issues when using Voxtype. - [Transcription Issues](#transcription-issues) - [Output Problems](#output-problems) - [wtype not working on KDE Plasma or GNOME Wayland](#wtype-not-working-on-kde-plasma-or-gnome-wayland) + - [Text output not working on X11](#text-output-not-working-on-x11) + - [Wrong characters on non-US keyboard layouts](#wrong-characters-on-non-us-keyboard-layouts-yz-swapped-qwertz-azerty) - [Performance Issues](#performance-issues) - [Systemd Service Issues](#systemd-service-issues) - [Debug Mode](#debug-mode) @@ -231,6 +233,29 @@ curl -L -o ~/.local/share/voxtype/models/ggml-base.en.bin \ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin ``` +### Voxtype crashes during transcription (Linux) + +**Cause:** On some Linux systems (particularly with glibc 2.42+ like Ubuntu 25.10), the whisper-rs FFI bindings crash due to C++ exceptions crossing the FFI boundary. + +**Solution:** Use the CLI backend which runs whisper-cli as a subprocess: + +```toml +[whisper] +backend = "cli" +``` + +This requires `whisper-cli` to be installed. Build it from [whisper.cpp](https://github.com/ggerganov/whisper.cpp): + +```bash +git clone https://github.com/ggerganov/whisper.cpp +cd whisper.cpp +cmake -B build +cmake --build build --config Release +sudo cp build/bin/whisper-cli /usr/local/bin/ +``` + +See [CLI Backend](USER_MANUAL.md#cli-backend-whisper-cli) in the User Manual for details. + ### Poor transcription accuracy **Possible causes:** @@ -374,6 +399,134 @@ mode = "paste" # Copies to clipboard, then simulates Ctrl+V --- +### Text output not working on X11 + +**Symptom:** You're running X11 (not Wayland) and see errors like: +``` +WARN wtype failed: Wayland connection failed +WARN clipboard (wl-copy) failed: Text injection failed +ERROR Output failed: All output methods failed. +``` + +**Cause:** wtype and wl-copy are Wayland-only tools. On X11, voxtype needs dotool, ydotool, or xclip installed. + +**Solution:** Install one of these X11-compatible tools: + +**Option 1 (Recommended): Install dotool** + +dotool works on X11, supports keyboard layouts, and doesn't need a daemon: + +```bash +# Ubuntu/Debian (from source): +sudo apt install libxkbcommon-dev +git clone https://git.sr.ht/~geb/dotool +cd dotool && ./build.sh && sudo cp dotool /usr/local/bin/ + +# Arch (AUR): +yay -S dotool + +# Add user to input group +sudo usermod -aG input $USER +# Log out and back in +``` + +**Option 2: Install ydotool** + +ydotool works on X11 but requires a running daemon: + +```bash +# Ubuntu/Debian: +sudo apt install ydotool + +# Start the daemon (see "ydotool daemon not running" section for Fedora) +systemctl --user enable --now ydotool +``` + +**Option 3: Use clipboard mode with xclip** + +For clipboard-only output (you paste manually with Ctrl+V): + +```bash +# Ubuntu/Debian: +sudo apt install xclip +``` + +Then configure voxtype to use clipboard mode: +```toml +[output] +mode = "clipboard" +``` + +**Verify your setup:** + +```bash +voxtype setup +``` + +This shows which output tools are installed and available. + +--- + +### Wrong characters on non-US keyboard layouts (y/z swapped, QWERTZ, AZERTY) + +**Symptom:** Transcribed text has wrong characters. For example, on a German keyboard, "Python" becomes "Pzthon" and "zebra" becomes "yebra" (y and z are swapped). + +**Cause:** ydotool sends raw US keycodes and doesn't support keyboard layouts. When voxtype falls back to ydotool (e.g., on X11, Cinnamon, or when wtype fails), characters are typed as if you had a US keyboard layout. + +**Solution:** Install dotool and configure your keyboard layout. Unlike ydotool, dotool supports keyboard layouts via XKB: + +```bash +# 1. Install dotool +# Arch (AUR): +yay -S dotool +# Ubuntu/Debian (from source): +# See https://sr.ht/~geb/dotool/ for instructions +# Fedora (from source): +# See https://sr.ht/~geb/dotool/ for instructions + +# 2. Add user to input group (required for uinput access) +sudo usermod -aG input $USER +# Log out and back in for group change to take effect + +# 3. Configure your keyboard layout in config.toml: +``` + +Add to `~/.config/voxtype/config.toml`: + +```toml +[output] +dotool_xkb_layout = "de" # German QWERTZ +``` + +Common layout codes: +- `de` - German (QWERTZ) +- `fr` - French (AZERTY) +- `es` - Spanish +- `uk` - Ukrainian +- `ru` - Russian +- `pl` - Polish +- `it` - Italian +- `pt` - Portuguese + +For layout variants (e.g., German without dead keys): + +```toml +[output] +dotool_xkb_layout = "de" +dotool_xkb_variant = "nodeadkeys" +``` + +**Alternative:** Use paste mode, which copies text to the clipboard and simulates Ctrl+V. This works regardless of keyboard layout: + +```toml +[output] +mode = "paste" +``` + +**Note:** The keyboard layout fix requires voxtype v0.5.0 or later. If you're on an older version, upgrade first. + +--- + ### "ydotool daemon not running" **Cause:** ydotool systemd service not started. From 88c9497b0be1656a103ab7511fa5c7da6dc6f479 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 18:16:01 -0500 Subject: [PATCH 04/33] Bump version to 0.6.0-rc1 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2d94aa3b..214bf83f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,7 +702,7 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" + source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ @@ -3701,7 +3701,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voxtype" -version = "0.5.5" +version = "0.6.0-rc1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b8cd8f27..b7b9ccd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["xtask"] [package] name = "voxtype" -version = "0.5.5" +version = "0.6.0-rc1" edition = "2021" authors = ["Peter Jackson", "Jean-Paul van Tillo", "Máté Rémiás", "Rob Zolkos", "Dan Heuckeroth", "Igor Warzocha", "Julian Kaiser", "Kevin Miller", "konnsim", "reisset", "Zubair", "Loki Coyote"] description = "Push-to-talk voice-to-text for Wayland" From f5283f24646302f542b7841d1f60961c33717b2c Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 18:19:58 -0500 Subject: [PATCH 05/33] Bump version to 0.6.0-rc.1, add Homebrew formula MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update version for macOS release candidate - Add Christopher Albert, André Silva, goodroot, Chmouel Boudjnah, Alexander Bosu-Kellett, ayoahha, and Thinh Vu to authors - Add Homebrew formula for macOS installation --- Cargo.lock | 2 +- Cargo.toml | 4 +- macos/VoxtypeSetup/build-app.sh | 0 packaging/homebrew/voxtype.rb | 98 +++++++++++++++++---------------- 4 files changed, 53 insertions(+), 51 deletions(-) mode change 100644 => 100755 macos/VoxtypeSetup/build-app.sh diff --git a/Cargo.lock b/Cargo.lock index 214bf83f..0fb893c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3701,7 +3701,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voxtype" -version = "0.6.0-rc1" +version = "0.6.0-rc.1" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index b7b9ccd7..92c4748d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,9 @@ members = ["xtask"] [package] name = "voxtype" -version = "0.6.0-rc1" +version = "0.6.0-rc.1" edition = "2021" -authors = ["Peter Jackson", "Jean-Paul van Tillo", "Máté Rémiás", "Rob Zolkos", "Dan Heuckeroth", "Igor Warzocha", "Julian Kaiser", "Kevin Miller", "konnsim", "reisset", "Zubair", "Loki Coyote"] +authors = ["Peter Jackson", "Jean-Paul van Tillo", "Máté Rémiás", "Rob Zolkos", "Dan Heuckeroth", "Igor Warzocha", "Julian Kaiser", "Kevin Miller", "konnsim", "reisset", "Zubair", "Loki Coyote", "Christopher Albert", "André Silva", "goodroot", "Chmouel Boudjnah", "Alexander Bosu-Kellett", "ayoahha", "Thinh Vu"] description = "Push-to-talk voice-to-text for Wayland" license = "MIT" readme = "README.md" diff --git a/macos/VoxtypeSetup/build-app.sh b/macos/VoxtypeSetup/build-app.sh old mode 100644 new mode 100755 diff --git a/packaging/homebrew/voxtype.rb b/packaging/homebrew/voxtype.rb index 106e8bbe..ac295b86 100644 --- a/packaging/homebrew/voxtype.rb +++ b/packaging/homebrew/voxtype.rb @@ -1,63 +1,65 @@ -# Homebrew Cask formula for Voxtype -# -# To use this cask: -# 1. Create a tap: brew tap peteonrails/voxtype -# 2. Install: brew install --cask voxtype -# -# Or install directly: -# brew install --cask peteonrails/voxtype/voxtype - -cask "voxtype" do - version "0.5.0" +class Voxtype < Formula + desc "Push-to-talk voice-to-text for macOS and Linux" + homepage "https://voxtype.io" + url "https://github.com/peteonrails/voxtype/archive/refs/tags/v0.6.0-rc.1.tar.gz" sha256 "PLACEHOLDER_SHA256" + license "MIT" + head "https://github.com/peteonrails/voxtype.git", branch: "feature/macos-release" - url "https://github.com/peteonrails/voxtype/releases/download/v#{version}/voxtype-#{version}-macos-universal.dmg", - verified: "github.com/peteonrails/voxtype/" - name "Voxtype" - desc "Push-to-talk voice-to-text using Whisper" - homepage "https://voxtype.io" + depends_on "cmake" => :build + depends_on "rust" => :build + depends_on "pkg-config" => :build - livecheck do - url :url - strategy :github_latest + # macOS dependencies + on_macos do + depends_on "portaudio" end - # Universal binary supports both Intel and Apple Silicon - depends_on macos: ">= :big_sur" + # Linux dependencies + on_linux do + depends_on "alsa-lib" + depends_on "libxkbcommon" + end - binary "voxtype" + def install + # Build release binary + system "cargo", "install", *std_cargo_args + end - postflight do - # Remind user about Accessibility permissions - ohai "Voxtype requires Accessibility permissions to detect hotkeys." - ohai "Grant access in: System Preferences > Privacy & Security > Accessibility" - ohai "" - ohai "Quick start:" - ohai " voxtype setup # Check dependencies, download model" - ohai " voxtype setup launchd # Install as LaunchAgent (auto-start)" - ohai " voxtype daemon # Start manually" + def post_install + # Create config directory + (var/"voxtype").mkpath end - uninstall launchctl: "io.voxtype.daemon" + def caveats + <<~EOS + To start using voxtype: - zap trash: [ - "~/Library/LaunchAgents/io.voxtype.daemon.plist", - "~/Library/Logs/voxtype", - "~/.config/voxtype", - "~/.local/share/voxtype", - ] + 1. Run the setup wizard: + voxtype setup macos - caveats <<~EOS - Voxtype requires Accessibility permissions to detect global hotkeys. + 2. Or start the daemon directly: + voxtype daemon - After installation: - 1. Open System Preferences > Privacy & Security > Accessibility - 2. Add and enable voxtype (or the Terminal app if running from terminal) + To have voxtype start at login: + voxtype setup launchd - To install as a LaunchAgent (auto-start on login): - voxtype setup launchd + Default hotkey: Right Option (⌥) - To start manually: - voxtype daemon - EOS + For more information: + voxtype --help + https://voxtype.io/docs + EOS + end + + service do + run [opt_bin/"voxtype", "daemon"] + keep_alive true + log_path var/"log/voxtype.log" + error_log_path var/"log/voxtype.log" + end + + test do + assert_match version.to_s, shell_output("#{bin}/voxtype --version") + end end From df79e87f6a962b42c36e92e51a1405ad35abde58 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 19:09:47 -0500 Subject: [PATCH 06/33] Document cargo clean requirement for GPU builds Stale build artifacts can cause GPU support to silently fail at runtime even when: - The build succeeds without errors - Binary size and checksum differ from previous builds - Version reports correctly Added warnings about running cargo clean before building with different feature sets, and a new "Functional Verification" section explaining how to verify GPU builds actually detect the GPU at runtime. --- CLAUDE.md | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a04acb4f..03dedfe9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -443,15 +443,25 @@ GPU acceleration is enabled via Cargo features: | `gpu-hipblas` | ROCm/HIP | AMD GPUs (alternative to Vulkan) | | `gpu-metal` | Metal | macOS (not applicable for Linux builds) | +**CRITICAL: Always run `cargo clean` before building with different features.** + +When switching between feature sets (e.g., CPU-only to GPU-enabled, or between different GPU backends), stale build artifacts can cause GPU support to silently fail at runtime. The binary will compile, have a different checksum, and appear correct, but GPU acceleration won't work. + +This is especially insidious because: +- The build succeeds without errors +- The binary size and checksum differ from previous builds +- `--version` reports correctly +- But GPU detection fails silently at runtime (e.g., `use gpu = 0` instead of `use gpu = 1`) + ```bash # Build with Vulkan GPU support -cargo build --release --features gpu-vulkan +cargo clean && cargo build --release --features gpu-vulkan # Build with CUDA GPU support -cargo build --release --features gpu-cuda +cargo clean && cargo build --release --features gpu-cuda # Build CPU-only (no GPU feature) -cargo build --release +cargo clean && cargo build --release ``` ### Remote Docker Context @@ -473,9 +483,13 @@ docker context use default ### Full Release Build Process -**CRITICAL: Always use `--no-cache` for release builds to prevent stale binaries.** +**CRITICAL: Always use `--no-cache` for Docker builds and `cargo clean` for local builds.** -Docker caches build layers aggressively. Without `--no-cache`, you may upload binaries with old version numbers even after updating Cargo.toml. This has caused AUR packages to ship v0.4.1 binaries labeled as v0.4.5. +Stale build artifacts cause two categories of failures: + +1. **Docker cache** - Without `--no-cache`, Docker may reuse layers with old version numbers. This caused AUR packages to ship v0.4.1 binaries labeled as v0.4.5. + +2. **Cargo incremental compilation** - Without `cargo clean`, switching between feature sets (e.g., CPU-only to `--features gpu-vulkan`) can produce binaries where GPU support silently fails at runtime. The binary compiles, has a different checksum, and reports the correct version, but GPU acceleration doesn't work. This is undetectable without actually testing GPU functionality. ```bash # Set version @@ -533,6 +547,28 @@ releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx512 --version If versions don't match, the Docker cache is stale. Rebuild with `--no-cache`. +### Functional Verification (GPU Builds) + +**Version checks and checksums are NOT sufficient to verify GPU builds.** A binary can report the correct version, have the expected file size, and still have non-functional GPU support due to stale build artifacts. + +For GPU-enabled binaries (Vulkan, CUDA, ROCm), verify GPU is actually detected: + +```bash +# Test Vulkan build - should show "use gpu = 1" and "ggml_vulkan: Found N devices" +./voxtype-${VERSION}-linux-x86_64-vulkan daemon & +sleep 3 +journalctl --user -u voxtype --since "10 seconds ago" | grep -E "(use gpu|ggml_vulkan|Found.*devices)" +# Expected: "use gpu = 1", "ggml_vulkan: Found 1 Vulkan devices" +# Bad: "use gpu = 0" or "no GPU found" + +# For Parakeet ROCm - should show ROCm execution provider +./voxtype-${VERSION}-linux-x86_64-parakeet-rocm daemon & +sleep 3 +journalctl --user -u voxtype --since "10 seconds ago" | grep -iE "(rocm|execution provider)" +``` + +If GPU detection fails but the binary otherwise works, the build used stale artifacts. Run `cargo clean` and rebuild. + ### Validating Binaries (AVX-512 Detection) Use `objdump` to verify binaries don't contain forbidden instructions: From 0df8b348bc88fc8588e234ccc3da08f64c35a101 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 20:06:51 -0500 Subject: [PATCH 07/33] Add X11 and GTK dependencies to Dockerfiles The rdev crate (for global hotkeys) and tray-icon crate require X11 and GTK development libraries on Linux. Added: - libx11-dev, libxi-dev, libxtst-dev (X11 input) - libgtk-3-dev, libglib2.0-dev, libappindicator3-dev (system tray) This fixes Docker builds after the macOS merge which added these dependencies via the tray-icon crate. --- Dockerfile.avx512 | 6 ++++++ Dockerfile.build | 7 +++++++ Dockerfile.parakeet | 6 ++++++ Dockerfile.parakeet-cuda | 6 ++++++ Dockerfile.vulkan | 6 ++++++ 5 files changed, 31 insertions(+) diff --git a/Dockerfile.avx512 b/Dockerfile.avx512 index 04df1662..5fd99d8e 100644 --- a/Dockerfile.avx512 +++ b/Dockerfile.avx512 @@ -20,6 +20,12 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libappindicator3-dev \ git \ binutils \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.build b/Dockerfile.build index e1218994..c727fa1a 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -23,6 +23,7 @@ ARG DEBIAN_FRONTEND=noninteractive ENV VERSION=${VERSION} # Install build dependencies (no Vulkan - see header comment) +# X11 and GTK deps required for rdev (hotkeys) and tray-icon crates RUN apt-get update && apt-get install -y \ curl \ build-essential \ @@ -30,6 +31,12 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libappindicator3-dev \ git \ binutils \ && rm -rf /var/lib/apt/lists/* diff --git a/Dockerfile.parakeet b/Dockerfile.parakeet index e964484a..11baf34b 100644 --- a/Dockerfile.parakeet +++ b/Dockerfile.parakeet @@ -32,6 +32,12 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libappindicator3-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ diff --git a/Dockerfile.parakeet-cuda b/Dockerfile.parakeet-cuda index 9e045a93..cd5d6a3a 100644 --- a/Dockerfile.parakeet-cuda +++ b/Dockerfile.parakeet-cuda @@ -27,6 +27,12 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libappindicator3-dev \ libssl-dev \ protobuf-compiler \ libprotobuf-dev \ diff --git a/Dockerfile.vulkan b/Dockerfile.vulkan index 162aed87..f76d2531 100644 --- a/Dockerfile.vulkan +++ b/Dockerfile.vulkan @@ -24,6 +24,12 @@ RUN apt-get update && apt-get install -y \ cmake \ pkg-config \ libasound2-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev \ + libgtk-3-dev \ + libglib2.0-dev \ + libappindicator3-dev \ git \ binutils \ libvulkan-dev \ From 2843b79593671dbe659fcfb88be16506f818b75c Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 20:29:26 -0500 Subject: [PATCH 08/33] macOS: Polish setup wizard for first release - Fix model selection to use static model lists (prevents infinite re-render loops) - Add real-time download progress bar that monitors file size on disk - Fix permissions flow to use manual confirmation (Open Settings + Done buttons) - Fix LaunchAgent detection to check for success message - Fix CLI command syntax for model downloads and engine switching - Tighten layouts to prevent button clipping on all screens - Add proper wizard completion state tracking - Fix PreferencesView to avoid @StateObject render loops - Add entitlements for AppleScript automation Co-Authored-By: Claude Opus 4.5 --- .../Sources/Preferences/PreferencesView.swift | 50 ++-- .../Sources/SetupWizard/CompleteView.swift | 47 ++-- .../SetupWizard/ModelSelectionView.swift | 216 +++++++++++++++--- .../Sources/SetupWizard/PermissionsView.swift | 90 +++++++- .../Sources/SetupWizard/SetupWizardView.swift | 6 +- .../Sources/SetupWizard/WelcomeView.swift | 14 +- .../Sources/Utilities/PermissionChecker.swift | 106 +++++---- .../Sources/Utilities/VoxtypeCLI.swift | 60 +++-- .../Sources/VoxtypeSetupApp.swift | 32 ++- macos/VoxtypeSetup/VoxtypeSetup.entitlements | 8 + macos/VoxtypeSetup/build-app.sh | 5 +- 11 files changed, 457 insertions(+), 177 deletions(-) create mode 100644 macos/VoxtypeSetup/VoxtypeSetup.entitlements diff --git a/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift b/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift index e34a0714..62fd5702 100644 --- a/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift +++ b/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift @@ -2,13 +2,13 @@ import SwiftUI struct PreferencesView: View { @EnvironmentObject var setupState: SetupState - @StateObject private var cli = VoxtypeCLI.shared @StateObject private var permissions = PermissionChecker.shared @State private var selectedEngine: TranscriptionEngine = .parakeet @State private var selectedModel: String = "" @State private var autoStartEnabled: Bool = false @State private var showingModelDownload = false + @State private var daemonStatus: String = "" var body: some View { VStack(spacing: 0) { @@ -52,7 +52,7 @@ struct PreferencesView: View { } .pickerStyle(.segmented) .onChange(of: selectedEngine) { _ in - _ = cli.setEngine(selectedEngine) + _ = VoxtypeCLI.shared.setEngine(selectedEngine) } Text("Model") @@ -77,7 +77,7 @@ struct PreferencesView: View { PermissionStatusRow( title: "Microphone", isGranted: permissions.hasMicrophoneAccess, - action: { permissions.requestMicrophoneAccess { _ in } } + action: { permissions.openMicrophoneSettings() } ) PermissionStatusRow( title: "Accessibility", @@ -99,9 +99,9 @@ struct PreferencesView: View { } .onChange(of: autoStartEnabled) { newValue in if newValue { - _ = cli.installLaunchAgent() + _ = VoxtypeCLI.shared.installLaunchAgent() } else { - _ = cli.uninstallLaunchAgent() + _ = VoxtypeCLI.shared.uninstallLaunchAgent() } } } @@ -110,11 +110,11 @@ struct PreferencesView: View { PreferenceSection(title: "Daemon", icon: "gearshape.2") { HStack { Button("Restart Daemon") { - cli.restartDaemon() + VoxtypeCLI.shared.restartDaemon() } Button("Stop Daemon") { - cli.stopDaemon() + VoxtypeCLI.shared.stopDaemon() } Spacer() @@ -150,8 +150,7 @@ struct PreferencesView: View { // Footer HStack { Button("Run Setup Again") { - setupState.setupComplete = false - setupState.currentStep = .welcome + setupState.resetWizard() } .buttonStyle(.borderless) @@ -175,11 +174,13 @@ struct PreferencesView: View { } var daemonRunning: Bool { - cli.getStatus() != "stopped" && !cli.getStatus().isEmpty + !daemonStatus.isEmpty && daemonStatus != "stopped" } func loadCurrentSettings() { + let cli = VoxtypeCLI.shared autoStartEnabled = cli.hasLaunchAgent() + daemonStatus = cli.getStatus() permissions.refresh() // Load current engine and model from config @@ -247,12 +248,32 @@ struct ModelDownloadSheet: View { let onSelect: (String) -> Void @Environment(\.dismiss) private var dismiss - @StateObject private var cli = VoxtypeCLI.shared @State private var selectedModel: String = "" @State private var isDownloading = false - var models: [ModelInfo] { - cli.availableModels().filter { $0.engine == selectedEngine } + // Static model lists + private let parakeetModels: [ModelInfo] = [ + ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, + description: "Fast, optimized for Apple Silicon", size: "670 MB"), + ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, + description: "Full precision", size: "1.2 GB"), + ] + + private let whisperModels: [ModelInfo] = [ + ModelInfo(name: "large-v3-turbo", engine: .whisper, + description: "Best accuracy, multilingual", size: "1.6 GB"), + ModelInfo(name: "medium.en", engine: .whisper, + description: "Good accuracy, English only", size: "1.5 GB"), + ModelInfo(name: "small.en", engine: .whisper, + description: "Balanced speed/accuracy", size: "500 MB"), + ModelInfo(name: "base.en", engine: .whisper, + description: "Fast, English only", size: "145 MB"), + ModelInfo(name: "tiny.en", engine: .whisper, + description: "Fastest, lower accuracy", size: "75 MB"), + ] + + private var models: [ModelInfo] { + selectedEngine == .parakeet ? parakeetModels : whisperModels } var body: some View { @@ -281,7 +302,7 @@ struct ModelDownloadSheet: View { .frame(height: 200) if isDownloading { - ProgressView(value: cli.downloadProgress) + ProgressView() .progressViewStyle(.linear) } @@ -306,6 +327,7 @@ struct ModelDownloadSheet: View { isDownloading = true Task { do { + let cli = VoxtypeCLI.shared try await cli.downloadModel(selectedModel, engine: selectedEngine) _ = cli.setModel(selectedModel, engine: selectedEngine) await MainActor.run { diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift index c544478c..5908e50e 100644 --- a/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift +++ b/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift @@ -4,46 +4,46 @@ struct CompleteView: View { @EnvironmentObject var setupState: SetupState var body: some View { - VStack(spacing: 30) { - Spacer() + VStack(spacing: 16) { + Spacer(minLength: 10) Image(systemName: "checkmark.circle.fill") .resizable() - .frame(width: 80, height: 80) + .frame(width: 60, height: 60) .foregroundColor(.green) - VStack(spacing: 12) { + VStack(spacing: 8) { Text("You're All Set!") - .font(.largeTitle) + .font(.title) .fontWeight(.bold) Text("Voxtype is ready to use") - .font(.title3) + .font(.body) .foregroundColor(.secondary) } // Usage instructions - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 12) { InstructionRow( step: "1", title: "Hold your hotkey", - description: "By default, hold the Right Option key to start recording" + description: "Hold the Right Option key to start recording" ) InstructionRow( step: "2", title: "Speak clearly", - description: "Talk normally - you'll see the orange mic indicator in your menu bar" + description: "You'll see an orange mic indicator in your menu bar" ) InstructionRow( step: "3", title: "Release to transcribe", - description: "Let go of the key and your speech will be typed at your cursor" + description: "Let go and your speech will be typed at your cursor" ) } - .padding(.horizontal, 60) - .padding(.vertical, 20) + .padding(.horizontal, 50) + .padding(.vertical, 12) // Hotkey reminder HStack { @@ -54,26 +54,28 @@ struct CompleteView: View { Text("Right Option (⌥)") .fontWeight(.medium) } - .padding() + .padding(10) .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(10) + .cornerRadius(8) - Spacer() + Spacer(minLength: 10) // Actions HStack(spacing: 20) { Button("Open Preferences") { - setupState.setupComplete = true + setupState.markWizardComplete() } .buttonStyle(WizardButtonStyle()) Button("Start Using Voxtype") { + // Save completion state and quit immediately + UserDefaults.standard.set(true, forKey: "wizardCompleted") NSApplication.shared.terminate(nil) } .buttonStyle(WizardButtonStyle(isPrimary: true)) } .padding(.horizontal, 40) - .padding(.bottom, 30) + .padding(.bottom, 20) } } } @@ -84,20 +86,21 @@ struct InstructionRow: View { let description: String var body: some View { - HStack(alignment: .top, spacing: 16) { + HStack(alignment: .top, spacing: 12) { Text(step) - .font(.title2) + .font(.body) .fontWeight(.bold) .foregroundColor(.white) - .frame(width: 32, height: 32) + .frame(width: 24, height: 24) .background(Color.accentColor) .clipShape(Circle()) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 1) { Text(title) + .font(.callout) .fontWeight(.medium) Text(description) - .font(.callout) + .font(.caption) .foregroundColor(.secondary) } diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift index 7c6f8362..03cdc36a 100644 --- a/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift +++ b/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift @@ -2,7 +2,7 @@ import SwiftUI struct ModelSelectionView: View { @EnvironmentObject var setupState: SetupState - @StateObject private var cli = VoxtypeCLI.shared + @StateObject private var downloadMonitor = DownloadMonitor() @State private var selectedEngine: TranscriptionEngine = .parakeet @State private var selectedModel: String = "parakeet-tdt-0.6b-v3-int8" @@ -10,8 +10,29 @@ struct ModelSelectionView: View { @State private var downloadComplete = false @State private var errorMessage: String? - var filteredModels: [ModelInfo] { - cli.availableModels().filter { $0.engine == selectedEngine } + // Static model lists to avoid repeated allocations + private let parakeetModels: [ModelInfo] = [ + ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, + description: "Fast, optimized for Apple Silicon", size: "670 MB"), + ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, + description: "Full precision", size: "1.2 GB"), + ] + + private let whisperModels: [ModelInfo] = [ + ModelInfo(name: "large-v3-turbo", engine: .whisper, + description: "Best accuracy, multilingual", size: "1.6 GB"), + ModelInfo(name: "medium.en", engine: .whisper, + description: "Good accuracy, English only", size: "1.5 GB"), + ModelInfo(name: "small.en", engine: .whisper, + description: "Balanced speed/accuracy", size: "500 MB"), + ModelInfo(name: "base.en", engine: .whisper, + description: "Fast, English only", size: "145 MB"), + ModelInfo(name: "tiny.en", engine: .whisper, + description: "Fastest, lower accuracy", size: "75 MB"), + ] + + private var displayedModels: [ModelInfo] { + selectedEngine == .parakeet ? parakeetModels : whisperModels } var body: some View { @@ -27,43 +48,59 @@ struct ModelSelectionView: View { // Engine picker Picker("Engine", selection: $selectedEngine) { - Text("Parakeet (Recommended for Mac)").tag(TranscriptionEngine.parakeet) + Text("Parakeet (Recommended)").tag(TranscriptionEngine.parakeet) Text("Whisper (Multilingual)").tag(TranscriptionEngine.whisper) } .pickerStyle(.segmented) .padding(.horizontal, 60) - .onChange(of: selectedEngine) { newValue in - // Select first model for the engine - selectedModel = filteredModels.first?.name ?? "" - } // Engine description - Text(engineDescription) + Text(selectedEngine == .parakeet + ? "Parakeet uses NVIDIA's FastConformer model, optimized for Apple Silicon. English only, but very fast." + : "Whisper is OpenAI's speech recognition model. Supports many languages with excellent accuracy.") .font(.callout) .foregroundColor(.secondary) .multilineTextAlignment(.center) .padding(.horizontal, 60) - // Model list - VStack(spacing: 8) { - ForEach(filteredModels) { model in - ModelRow( - model: model, - isSelected: selectedModel == model.name, - action: { selectedModel = model.name } - ) + // Model list (scrollable for longer lists) + ScrollView { + VStack(spacing: 8) { + if selectedEngine == .parakeet { + ForEach(parakeetModels) { model in + ModelRow( + model: model, + isSelected: selectedModel == model.name, + action: { selectedModel = model.name } + ) + } + } else { + ForEach(whisperModels) { model in + ModelRow( + model: model, + isSelected: selectedModel == model.name, + action: { selectedModel = model.name } + ) + } + } } + .padding(.horizontal, 40) } - .padding(.horizontal, 40) + .frame(maxHeight: 220) // Download progress if isDownloading { VStack(spacing: 8) { - ProgressView(value: cli.downloadProgress) + ProgressView(value: downloadMonitor.progress) .progressViewStyle(.linear) - Text("Downloading \(selectedModel)... \(Int(cli.downloadProgress * 100))%") + Text("Downloading \(selectedModel)... \(Int(downloadMonitor.progress * 100))%") .font(.callout) .foregroundColor(.secondary) + if downloadMonitor.downloadedSize > 0 { + Text("\(downloadMonitor.formattedDownloaded) / \(downloadMonitor.formattedTotal)") + .font(.caption) + .foregroundColor(.secondary) + } } .padding(.horizontal, 60) } @@ -97,7 +134,7 @@ struct ModelSelectionView: View { Spacer() - if downloadComplete || cli.hasModel() { + if downloadComplete { Button("Continue") { withAnimation { setupState.currentStep = .launchAgent @@ -115,14 +152,13 @@ struct ModelSelectionView: View { .padding(.horizontal, 40) .padding(.bottom, 30) } - } - - var engineDescription: String { - switch selectedEngine { - case .parakeet: - return "Parakeet uses NVIDIA's FastConformer model, optimized for Apple Silicon. English only, but very fast." - case .whisper: - return "Whisper is OpenAI's speech recognition model. Supports many languages with excellent accuracy." + .onChange(of: selectedEngine) { _ in + // Select first model when engine changes + if selectedEngine == .parakeet { + selectedModel = parakeetModels.first?.name ?? "" + } else { + selectedModel = whisperModels.first?.name ?? "" + } } } @@ -130,24 +166,144 @@ struct ModelSelectionView: View { isDownloading = true errorMessage = nil + // Get expected size and start monitoring + let expectedSize = getExpectedModelSize(selectedModel) + let modelsDir = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/voxtype/models") + downloadMonitor.startMonitoring(directory: modelsDir, expectedSize: expectedSize, modelName: selectedModel) + Task { do { + let cli = VoxtypeCLI.shared try await cli.downloadModel(selectedModel, engine: selectedEngine) await MainActor.run { + downloadMonitor.stopMonitoring() isDownloading = false downloadComplete = true - // Also set the model in config _ = cli.setEngine(selectedEngine) _ = cli.setModel(selectedModel, engine: selectedEngine) } } catch { await MainActor.run { + downloadMonitor.stopMonitoring() isDownloading = false errorMessage = "Download failed. Please check your internet connection." } } } } + + func getExpectedModelSize(_ model: String) -> Int64 { + // Expected sizes in bytes + switch model { + case "parakeet-tdt-0.6b-v3-int8": return 670_000_000 + case "parakeet-tdt-0.6b-v3": return 2_400_000_000 + case "large-v3-turbo": return 1_600_000_000 + case "medium.en": return 1_500_000_000 + case "small.en": return 500_000_000 + case "base.en": return 145_000_000 + case "tiny.en": return 75_000_000 + default: return 1_000_000_000 + } + } +} + +/// Monitors download progress by watching file/directory size +class DownloadMonitor: ObservableObject { + @Published var progress: Double = 0.0 + @Published var downloadedSize: Int64 = 0 + @Published var expectedSize: Int64 = 0 + + private var timer: Timer? + private var modelsDirectory: URL? + private var modelName: String = "" + private var initialSize: Int64 = 0 + + var formattedDownloaded: String { + ByteCountFormatter.string(fromByteCount: downloadedSize, countStyle: .file) + } + + var formattedTotal: String { + ByteCountFormatter.string(fromByteCount: expectedSize, countStyle: .file) + } + + func startMonitoring(directory: URL, expectedSize: Int64, modelName: String) { + self.modelsDirectory = directory + self.expectedSize = expectedSize + self.modelName = modelName + self.downloadedSize = 0 + self.progress = 0.0 + self.initialSize = getModelSize() + + // Poll every 0.3 seconds + timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in + self?.updateProgress() + } + } + + func stopMonitoring() { + timer?.invalidate() + timer = nil + progress = 1.0 + } + + private func updateProgress() { + let currentSize = getModelSize() + let downloaded = currentSize - initialSize + + DispatchQueue.main.async { + self.downloadedSize = downloaded + if self.expectedSize > 0 { + self.progress = min(Double(downloaded) / Double(self.expectedSize), 0.99) + } + } + } + + private func getModelSize() -> Int64 { + guard let modelsDir = modelsDirectory else { return 0 } + let fm = FileManager.default + + // For Parakeet models (directory) + let parakeetDir = modelsDir.appendingPathComponent(modelName) + if fm.fileExists(atPath: parakeetDir.path) { + return getDirectorySize(parakeetDir) + } + + // For Whisper models (single .bin file) + // Map model name to file name + let whisperFile: String + switch modelName { + case "large-v3-turbo": whisperFile = "ggml-large-v3-turbo.bin" + case "medium.en": whisperFile = "ggml-medium.en.bin" + case "small.en": whisperFile = "ggml-small.en.bin" + case "base.en": whisperFile = "ggml-base.en.bin" + case "tiny.en": whisperFile = "ggml-tiny.en.bin" + default: whisperFile = "ggml-\(modelName).bin" + } + + let whisperPath = modelsDir.appendingPathComponent(whisperFile) + if let attrs = try? fm.attributesOfItem(atPath: whisperPath.path), + let size = attrs[.size] as? Int64 { + return size + } + + return 0 + } + + private func getDirectorySize(_ url: URL) -> Int64 { + let fm = FileManager.default + guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else { + return 0 + } + + var total: Int64 = 0 + for case let fileURL as URL in enumerator { + if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { + total += Int64(size) + } + } + return total + } } struct ModelRow: View { diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift index 4411b88c..666e1d19 100644 --- a/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift +++ b/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift @@ -4,6 +4,7 @@ struct PermissionsView: View { @EnvironmentObject var setupState: SetupState @StateObject private var permissions = PermissionChecker.shared @State private var isCheckingPermissions = false + @State private var refreshTimer: Timer? var allPermissionsGranted: Bool { permissions.hasMicrophoneAccess && @@ -25,33 +26,42 @@ struct PermissionsView: View { .padding(.bottom, 10) VStack(spacing: 16) { - PermissionRow( + ManualPermissionRow( title: "Microphone", - description: "To capture your voice for transcription", + description: "Add Voxtype.app to Microphone list", icon: "mic.fill", isGranted: permissions.hasMicrophoneAccess, - action: { - permissions.requestMicrophoneAccess { _ in } + openAction: { + permissions.openMicrophoneSettings() + }, + confirmAction: { + permissions.confirmMicrophoneAccess() } ) - PermissionRow( + ManualPermissionRow( title: "Accessibility", - description: "To type transcribed text into applications", + description: "Add Voxtype.app to Accessibility list", icon: "accessibility", isGranted: permissions.hasAccessibilityAccess, - action: { + openAction: { permissions.requestAccessibilityAccess() + }, + confirmAction: { + permissions.confirmAccessibilityAccess() } ) - PermissionRow( + ManualPermissionRow( title: "Input Monitoring", - description: "To detect your push-to-talk hotkey", + description: "Add Voxtype.app to Input Monitoring list", icon: "keyboard", isGranted: permissions.hasInputMonitoringAccess, - action: { + openAction: { permissions.openInputMonitoringSettings() + }, + confirmAction: { + permissions.confirmInputMonitoringAccess() } ) } @@ -113,6 +123,16 @@ struct PermissionsView: View { .padding(.horizontal, 40) .padding(.bottom, 30) } + .onAppear { + // Start polling for permission changes + refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in + permissions.refresh() + } + } + .onDisappear { + refreshTimer?.invalidate() + refreshTimer = nil + } } } @@ -158,6 +178,56 @@ struct PermissionRow: View { } } +struct ManualPermissionRow: View { + let title: String + let description: String + let icon: String + let isGranted: Bool + let openAction: () -> Void + let confirmAction: () -> Void + + var body: some View { + HStack(spacing: 16) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(isGranted ? .green : .accentColor) + .frame(width: 30) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .fontWeight(.medium) + Text(description) + .font(.callout) + .foregroundColor(.secondary) + } + + Spacer() + + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.title2) + } else { + HStack(spacing: 8) { + Button("Open Settings") { + openAction() + } + .controlSize(.small) + + Button("Done") { + confirmAction() + } + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + } + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(10) + } +} + #Preview { PermissionsView() .environmentObject(SetupState()) diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift index 1a7b2635..42452590 100644 --- a/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift +++ b/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift @@ -8,8 +8,7 @@ struct SetupWizardView: View { // Progress indicator ProgressBar(currentStep: setupState.currentStep) .padding(.horizontal, 40) - .padding(.top, 30) - .padding(.bottom, 20) + .padding(.vertical, 16) Divider() @@ -28,9 +27,8 @@ struct SetupWizardView: View { CompleteView() } } - .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(width: 600, height: 500) + .frame(width: 600, height: 550) .background(Color(NSColor.windowBackgroundColor)) } } diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift index 67dea398..078d645c 100644 --- a/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift +++ b/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift @@ -4,16 +4,16 @@ struct WelcomeView: View { @EnvironmentObject var setupState: SetupState var body: some View { - VStack(spacing: 30) { + VStack(spacing: 20) { Spacer() // App icon placeholder Image(systemName: "mic.circle.fill") .resizable() - .frame(width: 80, height: 80) + .frame(width: 70, height: 70) .foregroundColor(.accentColor) - VStack(spacing: 12) { + VStack(spacing: 8) { Text("Welcome to Voxtype") .font(.largeTitle) .fontWeight(.bold) @@ -23,7 +23,7 @@ struct WelcomeView: View { .foregroundColor(.secondary) } - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { FeatureRow(icon: "hand.tap", title: "Push-to-Talk", description: "Hold a key to record, release to transcribe") FeatureRow(icon: "text.cursor", title: "Type Anywhere", @@ -31,8 +31,8 @@ struct WelcomeView: View { FeatureRow(icon: "bolt", title: "Fast & Private", description: "Runs locally on your Mac, no cloud required") } - .padding(.horizontal, 60) - .padding(.vertical, 20) + .padding(.horizontal, 50) + .padding(.vertical, 16) Spacer() @@ -47,7 +47,7 @@ struct WelcomeView: View { .buttonStyle(WizardButtonStyle(isPrimary: true)) } .padding(.horizontal, 40) - .padding(.bottom, 30) + .padding(.bottom, 24) } } } diff --git a/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift b/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift index 4ce3290d..9930c254 100644 --- a/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift +++ b/macos/VoxtypeSetup/Sources/Utilities/PermissionChecker.swift @@ -24,76 +24,88 @@ class PermissionChecker: ObservableObject { // MARK: - Microphone private func checkMicrophoneAccess() { - switch AVCaptureDevice.authorizationStatus(for: .audio) { - case .authorized: - hasMicrophoneAccess = true - case .notDetermined, .denied, .restricted: - hasMicrophoneAccess = false - @unknown default: - hasMicrophoneAccess = false - } + // Check confirmation from user (permission is for Voxtype.app, not this app) + hasMicrophoneAccess = UserDefaults.standard.bool(forKey: "microphoneConfirmed") } - func requestMicrophoneAccess(completion: @escaping (Bool) -> Void) { - AVCaptureDevice.requestAccess(for: .audio) { granted in - DispatchQueue.main.async { - self.hasMicrophoneAccess = granted - completion(granted) - } - } + func openMicrophoneSettings() { + // Use osascript to open Microphone privacy settings directly + let script = """ + tell application "System Settings" + activate + reveal anchor "Privacy_Microphone" of pane id "com.apple.settings.PrivacySecurity.extension" + end tell + """ + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + try? process.run() + } + + func confirmMicrophoneAccess() { + UserDefaults.standard.set(true, forKey: "microphoneConfirmed") + hasMicrophoneAccess = true } // MARK: - Accessibility private func checkAccessibilityAccess() { - hasAccessibilityAccess = AXIsProcessTrusted() + // Check if THIS app (setup wizard) is trusted + // Note: Main Voxtype.app permission must be confirmed manually + hasAccessibilityAccess = UserDefaults.standard.bool(forKey: "accessibilityConfirmed") } func requestAccessibilityAccess() { - // This opens System Settings to the Accessibility pane - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] - AXIsProcessTrustedWithOptions(options as CFDictionary) - - // Also open the settings pane directly for clarity + // Open System Settings to Accessibility openAccessibilitySettings() } + func confirmAccessibilityAccess() { + UserDefaults.standard.set(true, forKey: "accessibilityConfirmed") + hasAccessibilityAccess = true + } + func openAccessibilitySettings() { - let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")! - NSWorkspace.shared.open(url) + // Use osascript to open Accessibility directly + let script = """ + tell application "System Settings" + activate + reveal anchor "Privacy_Accessibility" of pane id "com.apple.settings.PrivacySecurity.extension" + end tell + """ + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + try? process.run() } // MARK: - Input Monitoring private func checkInputMonitoringAccess() { - // There's no direct API to check Input Monitoring permission - // We use a heuristic: try to create an event tap - // If it fails, we likely don't have permission - hasInputMonitoringAccess = canCreateEventTap() + // Check confirmation from user + hasInputMonitoringAccess = UserDefaults.standard.bool(forKey: "inputMonitoringConfirmed") } - private func canCreateEventTap() -> Bool { - // Attempt to create a passive event tap - // This will fail if Input Monitoring permission is not granted - let eventMask = (1 << CGEventType.keyDown.rawValue) - guard let tap = CGEvent.tapCreate( - tap: .cgSessionEventTap, - place: .headInsertEventTap, - options: .listenOnly, - eventsOfInterest: CGEventMask(eventMask), - callback: { _, _, event, _ in Unmanaged.passUnretained(event) }, - userInfo: nil - ) else { - return false - } - // Clean up the tap - CFMachPortInvalidate(tap) - return true + func openInputMonitoringSettings() { + // Use osascript to open Input Monitoring directly + let script = """ + tell application "System Settings" + activate + reveal anchor "Privacy_ListenEvent" of pane id "com.apple.settings.PrivacySecurity.extension" + end tell + """ + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript") + process.arguments = ["-e", script] + try? process.run() } - func openInputMonitoringSettings() { - let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent")! - NSWorkspace.shared.open(url) + func confirmInputMonitoringAccess() { + UserDefaults.standard.set(true, forKey: "inputMonitoringConfirmed") + hasInputMonitoringAccess = true } // MARK: - Notifications (optional) diff --git a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift index c8218eda..cb24a467 100644 --- a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift +++ b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift @@ -34,6 +34,21 @@ class VoxtypeCLI: ObservableObject { return !output.contains("model not found") && !output.contains("No model") } + /// Check if Voxtype has accessibility permission + /// Since we can't directly query another app's TCC status, we check if + /// the binary exists and is executable (user confirms in UI) + func checkAccessibilityPermission() -> Bool { + // We can't check another process's accessibility status + // Return true if voxtype binary exists (user must confirm manually) + return FileManager.default.isExecutableFile(atPath: binaryPath) + } + + /// Check if Voxtype has input monitoring permission + func checkInputMonitoringPermission() -> Bool { + // Same limitation - we can't check another process's TCC status + return FileManager.default.isExecutableFile(atPath: binaryPath) + } + /// Check if LaunchAgent is installed func hasLaunchAgent() -> Bool { let plistPath = FileManager.default.homeDirectoryForCurrentUser @@ -92,13 +107,8 @@ class VoxtypeCLI: ObservableObject { } } - let args: [String] - switch engine { - case .parakeet: - args = ["setup", "parakeet", "--download", model] - case .whisper: - args = ["setup", "model", "--download", model] - } + // Correct syntax: voxtype setup --download --model + let args = ["setup", "--download", "--model", model] // Run with progress monitoring let process = Process() @@ -136,10 +146,12 @@ class VoxtypeCLI: ObservableObject { } private func parseProgress(_ line: String) -> Double? { - // Parse progress from CLI output like "Downloading... 45%" - if let range = line.range(of: #"(\d+)%"#, options: .regularExpression), - let percent = Double(line[range].dropLast()) { - return percent / 100.0 + // Parse progress from CLI output like "2.5%" or "100.0%" + if let range = line.range(of: #"(\d+\.?\d*)%"#, options: .regularExpression) { + let percentStr = String(line[range].dropLast()) // Remove the % + if let percent = Double(percentStr) { + return percent / 100.0 + } } return nil } @@ -148,30 +160,32 @@ class VoxtypeCLI: ObservableObject { /// Set the transcription engine func setEngine(_ engine: TranscriptionEngine) -> Bool { - let engineStr = engine == .parakeet ? "parakeet" : "whisper" - let output = run(["config", "set", "engine", engineStr]) - return !output.contains("error") - } - - /// Set the model - func setModel(_ model: String, engine: TranscriptionEngine) -> Bool { let args: [String] switch engine { case .parakeet: - args = ["setup", "parakeet", "--set", model] + args = ["setup", "parakeet", "--enable"] case .whisper: - args = ["setup", "model", "--set", model] + args = ["setup", "parakeet", "--disable"] } let output = run(args) return !output.contains("error") } + /// Set the model + func setModel(_ model: String, engine: TranscriptionEngine) -> Bool { + // Use setup model --set for both engines + let output = run(["setup", "model", "--set", model]) + return !output.contains("error") + } + // MARK: - LaunchAgent /// Install the LaunchAgent for auto-start func installLaunchAgent() -> Bool { let output = run(["setup", "launchd"]) - return !output.contains("error") && !output.contains("failed") + // Check for success message rather than absence of "failed" + // (launchctl may show "Load failed" warning but still succeed) + return output.contains("Installation complete") } /// Uninstall the LaunchAgent @@ -257,11 +271,13 @@ enum TranscriptionEngine: String, CaseIterable { } struct ModelInfo: Identifiable { - let id = UUID() let name: String let engine: TranscriptionEngine let description: String let size: String + + // Use name as stable identifier instead of UUID + var id: String { name } } enum CLIError: Error { diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift index 554b81af..a70aac17 100644 --- a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -14,8 +14,6 @@ struct VoxtypeSetupApp: App { .environmentObject(setupState) } } - .windowStyle(.hiddenTitleBar) - .windowResizability(.contentSize) } } @@ -24,28 +22,24 @@ class SetupState: ObservableObject { @Published var setupComplete: Bool = false @Published var currentStep: SetupStep = .welcome + private let wizardCompletedKey = "wizardCompleted" + init() { - // Check if setup has been completed before - setupComplete = checkSetupComplete() + // Only show preferences if wizard was explicitly completed + setupComplete = UserDefaults.standard.bool(forKey: wizardCompletedKey) } - private func checkSetupComplete() -> Bool { - // Setup is complete if: - // 1. All permissions are granted - // 2. A model is downloaded - // 3. LaunchAgent is installed - let permissions = PermissionChecker.shared - let hasPermissions = permissions.hasMicrophoneAccess && - permissions.hasAccessibilityAccess && - permissions.hasInputMonitoringAccess - let hasModel = VoxtypeCLI.shared.hasModel() - let hasLaunchAgent = VoxtypeCLI.shared.hasLaunchAgent() - - return hasPermissions && hasModel && hasLaunchAgent + /// Mark wizard as completed (called when user finishes the wizard) + func markWizardComplete() { + UserDefaults.standard.set(true, forKey: wizardCompletedKey) + setupComplete = true } - func recheckSetup() { - setupComplete = checkSetupComplete() + /// Reset to show wizard again + func resetWizard() { + UserDefaults.standard.set(false, forKey: wizardCompletedKey) + setupComplete = false + currentStep = .welcome } } diff --git a/macos/VoxtypeSetup/VoxtypeSetup.entitlements b/macos/VoxtypeSetup/VoxtypeSetup.entitlements new file mode 100644 index 00000000..cffd6379 --- /dev/null +++ b/macos/VoxtypeSetup/VoxtypeSetup.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.automation.apple-events + + + diff --git a/macos/VoxtypeSetup/build-app.sh b/macos/VoxtypeSetup/build-app.sh index d20d4685..bcabdefb 100755 --- a/macos/VoxtypeSetup/build-app.sh +++ b/macos/VoxtypeSetup/build-app.sh @@ -54,8 +54,9 @@ cat > "$CONTENTS/Info.plist" << 'EOF' EOF -# Sign the app -codesign --force --deep --sign - "$APP_BUNDLE" +# Sign the app with entitlements +ENTITLEMENTS="$SCRIPT_DIR/VoxtypeSetup.entitlements" +codesign --force --deep --sign - --entitlements "$ENTITLEMENTS" "$APP_BUNDLE" echo "Built: $APP_BUNDLE" echo "" From 4a0bc1e294880f9c0406bfc9fea63aea25b2d91c Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Thu, 29 Jan 2026 20:55:44 -0500 Subject: [PATCH 09/33] Homebrew: Enable parakeet for macOS builds macOS builds now always include Parakeet support via --features parakeet. Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/voxtype.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packaging/homebrew/voxtype.rb b/packaging/homebrew/voxtype.rb index ac295b86..a33fd715 100644 --- a/packaging/homebrew/voxtype.rb +++ b/packaging/homebrew/voxtype.rb @@ -22,8 +22,12 @@ class Voxtype < Formula end def install - # Build release binary - system "cargo", "install", *std_cargo_args + # Build release binary with parakeet support on macOS + if OS.mac? + system "cargo", "install", *std_cargo_args, "--features", "parakeet" + else + system "cargo", "install", *std_cargo_args + end end def post_install From 1ae51be0507f4980c1a9ca204236456127f7c844 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 15:50:10 -0500 Subject: [PATCH 10/33] Fix Parakeet quantized model detection in setup check - Detect quantized model files (.int8.onnx) in addition to standard .onnx - Skip Whisper model check when using Parakeet engine - Show helpful message that Whisper model is not required for Parakeet The setup check was failing to find parakeet-tdt-0.6b-v3-int8 because it only looked for encoder-model.onnx and decoder_joint-model.onnx, not the quantized variants with .int8.onnx extension. Co-Authored-By: Claude Opus 4.5 --- src/setup/mod.rs | 46 ++++++++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 25900557..12ab61f5 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -774,24 +774,29 @@ pub async fn run_checks(config: &Config) -> anyhow::Result<()> { } } - // Check whisper model - println!("\nWhisper Model:"); - let model_name = &config.whisper.model; - let model_filename = crate::transcribe::whisper::get_model_filename(model_name); - let model_path = models_dir.join(&model_filename); - - if model_path.exists() { - let size = std::fs::metadata(&model_path) - .map(|m| m.len() as f64 / 1024.0 / 1024.0) - .unwrap_or(0.0); - print_success(&format!( - "Model '{}' installed ({:.0} MB)", - model_name, size - )); + // Check whisper model (only if using Whisper engine) + if config.engine == crate::config::TranscriptionEngine::Whisper { + println!("\nWhisper Model:"); + let model_name = &config.whisper.model; + let model_filename = crate::transcribe::whisper::get_model_filename(model_name); + let model_path = models_dir.join(&model_filename); + + if model_path.exists() { + let size = std::fs::metadata(&model_path) + .map(|m| m.len() as f64 / 1024.0 / 1024.0) + .unwrap_or(0.0); + print_success(&format!( + "Model '{}' installed ({:.0} MB)", + model_name, size + )); + } else { + print_failure(&format!("Model '{}' not found", model_name)); + println!(" Run: voxtype setup --download"); + all_ok = false; + } } else { - print_failure(&format!("Model '{}' not found", model_name)); - println!(" Run: voxtype setup --download"); - all_ok = false; + println!("\nWhisper Model:"); + print_info("Using Parakeet engine (Whisper model not required)"); } // Check Parakeet models (experimental) @@ -805,10 +810,11 @@ pub async fn run_checks(config: &Config) -> anyhow::Result<()> { if path.is_dir() { let name = entry.file_name().to_string_lossy().to_string(); if name.contains("parakeet") { - // Check if it has the required ONNX files - let encoder_path = path.join("encoder-model.onnx"); - let has_encoder = encoder_path.exists(); + // Check if it has the required ONNX files (including quantized variants) + let has_encoder = path.join("encoder-model.onnx").exists() + || path.join("encoder-model.int8.onnx").exists(); let has_decoder = path.join("decoder_joint-model.onnx").exists() + || path.join("decoder_joint-model.int8.onnx").exists() || path.join("model.onnx").exists(); if has_encoder || has_decoder { // Get total size of model files From 1810cca573697db4c326187fba1fa038b82d547b Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 15:50:23 -0500 Subject: [PATCH 11/33] Add Homebrew Cask for macOS prebuilt binary installation Cask approach: - Installs prebuilt DMG to /Applications/Voxtype.app - Creates CLI symlink in $(brew --prefix)/bin - Works around Homebrew sandbox restrictions - Adds caveat for xattr quarantine removal if needed Formula updates: - Creates app bundle in Homebrew prefix during post_install - Symlinks to ~/Applications for permission grants - Adds service support via brew services - Updated caveats with permission grant instructions The Cask is the recommended installation method for most users. The Formula remains available for building from source. Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 62 ++++++++++++++++++ packaging/homebrew/voxtype.rb | 97 +++++++++++++++++++++++++---- 2 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 packaging/homebrew/Casks/voxtype.rb diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb new file mode 100644 index 00000000..f64f195b --- /dev/null +++ b/packaging/homebrew/Casks/voxtype.rb @@ -0,0 +1,62 @@ +cask "voxtype" do + version "0.6.0-rc1" + sha256 "32aaaacc37688996f68588e45da2a53bbc05783591d78a07be58be28d0c044c0" + + url "https://github.com/peteonrails/voxtype/releases/download/v#{version}/Voxtype-#{version}-macos-arm64.dmg" + name "Voxtype" + desc "Push-to-talk voice-to-text for macOS" + homepage "https://voxtype.io" + + livecheck do + url :url + strategy :github_latest + end + + depends_on macos: ">= :ventura" + + app "Voxtype.app" + + postflight do + # Create config directory + system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Application Support/voxtype"] + + # Create symlink for CLI access + system_command "/bin/ln", args: ["-sf", "/Applications/Voxtype.app/Contents/MacOS/voxtype", "#{HOMEBREW_PREFIX}/bin/voxtype"] + end + + uninstall_postflight do + # Remove CLI symlink + system_command "/bin/rm", args: ["-f", "#{HOMEBREW_PREFIX}/bin/voxtype"] + end + + uninstall quit: "io.voxtype.app" + + zap trash: [ + "~/Library/Application Support/voxtype", + "~/Library/LaunchAgents/io.voxtype.daemon.plist", + "~/Library/Logs/voxtype", + ] + + caveats <<~EOS + If macOS says the app is "damaged", run: + xattr -cr /Applications/Voxtype.app + + To complete setup: + + 1. Download a speech model: + voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 + + 2. Grant permissions in System Settings > Privacy & Security: + - Microphone: Add Voxtype + - Input Monitoring: Add Voxtype + - Accessibility: Add Voxtype + + 3. Start the daemon: + voxtype daemon + + To start at login: + voxtype setup launchd + + Default hotkey: Right Option + EOS +end diff --git a/packaging/homebrew/voxtype.rb b/packaging/homebrew/voxtype.rb index a33fd715..53faf425 100644 --- a/packaging/homebrew/voxtype.rb +++ b/packaging/homebrew/voxtype.rb @@ -33,31 +33,104 @@ def install def post_install # Create config directory (var/"voxtype").mkpath + + # Create app bundle for macOS permissions + if OS.mac? + # Create app bundle in Homebrew prefix (writable by Homebrew) + app_path = prefix/"Voxtype.app" + contents_path = app_path/"Contents" + macos_path = contents_path/"MacOS" + resources_path = contents_path/"Resources" + + # Create directory structure + macos_path.mkpath + resources_path.mkpath + + # Copy binary to app bundle + cp bin/"voxtype", macos_path/"voxtype" + + # Create Info.plist + info_plist = <<~PLIST + + + + + CFBundleExecutable + voxtype + CFBundleIdentifier + io.voxtype.app + CFBundleName + Voxtype + CFBundleDisplayName + Voxtype + CFBundlePackageType + APPL + CFBundleShortVersionString + #{version} + CFBundleVersion + #{version} + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + NSMicrophoneUsageDescription + Voxtype needs microphone access to capture your voice for speech-to-text transcription. + NSAppleEventsUsageDescription + Voxtype needs to send keystrokes to type transcribed text into applications. + + + PLIST + + (contents_path/"Info.plist").write(info_plist) + + # Sign the app bundle + system "codesign", "--force", "--deep", "--sign", "-", app_path + + # Create symlink in ~/Applications for easy access + user_apps = Pathname.new(Dir.home)/"Applications" + user_apps.mkpath rescue nil + user_app_link = user_apps/"Voxtype.app" + + # Remove old symlink/app if exists + user_app_link.rmtree if user_app_link.exist? || user_app_link.symlink? + + # Create symlink + begin + FileUtils.ln_sf(app_path, user_app_link) + ohai "Created #{user_app_link} -> #{app_path}" + rescue => e + opoo "Could not create symlink in ~/Applications: #{e.message}" + end + end end def caveats <<~EOS - To start using voxtype: + Voxtype.app has been installed and linked to ~/Applications. - 1. Run the setup wizard: - voxtype setup macos + To complete setup: - 2. Or start the daemon directly: - voxtype daemon + 1. Download a speech model: + voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 - To have voxtype start at login: - voxtype setup launchd + 2. Grant permissions in System Settings > Privacy & Security: + • Microphone: Add Voxtype (from ~/Applications) + • Input Monitoring: Add Voxtype (from ~/Applications) + • Accessibility: Add Voxtype (from ~/Applications) - Default hotkey: Right Option (⌥) + 3. Start the daemon: + brew services start voxtype - For more information: - voxtype --help - https://voxtype.io/docs + Default hotkey: Right Option (⌥) + More info: voxtype --help EOS end service do - run [opt_bin/"voxtype", "daemon"] + # Use app bundle path for proper macOS permissions + run [opt_prefix/"Voxtype.app/Contents/MacOS/voxtype", "daemon"] keep_alive true log_path var/"log/voxtype.log" error_log_path var/"log/voxtype.log" From 6c3adb901d26a98d935c6003334efb9b4cc6fbcb Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 16:01:19 -0500 Subject: [PATCH 12/33] Update Cask SHA256 for rebuilt DMG with Parakeet fix Rebuilt the macOS DMG after the Parakeet model detection fix was applied. The new DMG contains a binary that correctly detects quantized .int8.onnx model files. Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index f64f195b..0973e50f 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -1,6 +1,6 @@ cask "voxtype" do version "0.6.0-rc1" - sha256 "32aaaacc37688996f68588e45da2a53bbc05783591d78a07be58be28d0c044c0" + sha256 "ad5c4f2531ed50ed028ec7e85062abeb2e64c27e8d1becb84b4946b631ba7aeb" url "https://github.com/peteonrails/voxtype/releases/download/v#{version}/Voxtype-#{version}-macos-arm64.dmg" name "Voxtype" From 6e9020eef3ace9f56a73bbed1258bf3070ec9891 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 18:11:26 -0500 Subject: [PATCH 13/33] Auto-start daemon via LaunchAgent in Cask postflight - Install LaunchAgent plist during brew install - Load the agent so daemon starts immediately - Auto-restart daemon if it crashes (KeepAlive) - Unload and remove LaunchAgent on uninstall - Updated caveats to reflect auto-start behavior Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 62 ++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 6 deletions(-) diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index 0973e50f..cdcabae5 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -20,11 +20,62 @@ # Create config directory system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Application Support/voxtype"] + # Create logs directory + system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Logs/voxtype"] + # Create symlink for CLI access system_command "/bin/ln", args: ["-sf", "/Applications/Voxtype.app/Contents/MacOS/voxtype", "#{HOMEBREW_PREFIX}/bin/voxtype"] + + # Install LaunchAgent for auto-start + launch_agents_dir = "#{ENV["HOME"]}/Library/LaunchAgents" + system_command "/bin/mkdir", args: ["-p", launch_agents_dir] + + plist_path = "#{launch_agents_dir}/io.voxtype.daemon.plist" + plist_content = <<~PLIST + + + + + Label + io.voxtype.daemon + ProgramArguments + + /Applications/Voxtype.app/Contents/MacOS/voxtype + daemon + + RunAtLoad + + KeepAlive + + StandardOutPath + #{ENV["HOME"]}/Library/Logs/voxtype/stdout.log + StandardErrorPath + #{ENV["HOME"]}/Library/Logs/voxtype/stderr.log + EnvironmentVariables + + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + + ProcessType + Interactive + Nice + -10 + + + PLIST + + File.write(plist_path, plist_content) + + # Load the LaunchAgent + system_command "/bin/launchctl", args: ["load", plist_path] end uninstall_postflight do + # Unload and remove LaunchAgent + plist_path = "#{ENV["HOME"]}/Library/LaunchAgents/io.voxtype.daemon.plist" + system_command "/bin/launchctl", args: ["unload", plist_path] if File.exist?(plist_path) + system_command "/bin/rm", args: ["-f", plist_path] + # Remove CLI symlink system_command "/bin/rm", args: ["-f", "#{HOMEBREW_PREFIX}/bin/voxtype"] end @@ -41,6 +92,8 @@ If macOS says the app is "damaged", run: xattr -cr /Applications/Voxtype.app + The daemon starts automatically at login. + To complete setup: 1. Download a speech model: @@ -51,12 +104,9 @@ - Input Monitoring: Add Voxtype - Accessibility: Add Voxtype - 3. Start the daemon: - voxtype daemon - - To start at login: - voxtype setup launchd + Default hotkey: Right Option (hold to record) - Default hotkey: Right Option + Optional: Run the menu bar helper for status icon: + voxtype menubar EOS end From d54521c6829221a6438b106ab6a375fad798fe7f Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 18:15:32 -0500 Subject: [PATCH 14/33] Auto-remove quarantine in Cask postflight for seamless install - Run xattr -cr on app bundle to remove Gatekeeper quarantine - Simplify caveats now that xattr is automatic - Users no longer need manual steps to bypass "damaged app" error Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index cdcabae5..6cc2c5fe 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -17,6 +17,9 @@ app "Voxtype.app" postflight do + # Remove quarantine attribute (app is unsigned) + system_command "/usr/bin/xattr", args: ["-cr", "/Applications/Voxtype.app"] + # Create config directory system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Application Support/voxtype"] @@ -89,24 +92,18 @@ ] caveats <<~EOS - If macOS says the app is "damaged", run: - xattr -cr /Applications/Voxtype.app - - The daemon starts automatically at login. + Voxtype is installed and the daemon will start automatically. To complete setup: 1. Download a speech model: voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 - 2. Grant permissions in System Settings > Privacy & Security: - - Microphone: Add Voxtype - - Input Monitoring: Add Voxtype - - Accessibility: Add Voxtype + 2. Grant permissions when prompted, or manually in System Settings: + Privacy & Security > Microphone, Input Monitoring, Accessibility - Default hotkey: Right Option (hold to record) + Default hotkey: Right Option (hold to record, release to transcribe) - Optional: Run the menu bar helper for status icon: - voxtype menubar + For menu bar status icon: voxtype menubar EOS end From 3e75c0f5652052b5220fda38496dfadb8c93f63d Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 18:25:16 -0500 Subject: [PATCH 15/33] Clean stale state and improve Cask caveats - Remove /tmp/voxtype during install to prevent stale lock issues - Clarify first-time setup steps in caveats: 1. Click "Open Anyway" for unsigned app 2. Download model 3. Grant Input Monitoring permission - Emphasize Input Monitoring is required for hotkey Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index 6cc2c5fe..ea2974ae 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -20,6 +20,9 @@ # Remove quarantine attribute (app is unsigned) system_command "/usr/bin/xattr", args: ["-cr", "/Applications/Voxtype.app"] + # Clean up any stale state from previous installs + system_command "/bin/rm", args: ["-rf", "/tmp/voxtype"] + # Create config directory system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Application Support/voxtype"] @@ -92,15 +95,18 @@ ] caveats <<~EOS - Voxtype is installed and the daemon will start automatically. + Voxtype is installed and will start automatically at login. + + First-time setup: - To complete setup: + 1. If prompted "Voxtype was blocked", go to System Settings > + Privacy & Security and click "Open Anyway" - 1. Download a speech model: + 2. Download a speech model: voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 - 2. Grant permissions when prompted, or manually in System Settings: - Privacy & Security > Microphone, Input Monitoring, Accessibility + 3. Grant Input Monitoring permission in System Settings > + Privacy & Security > Input Monitoring (required for hotkey) Default hotkey: Right Option (hold to record, release to transcribe) From 0dceea512c1f1b6a1982a5dacbcceb4630639a90 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 19:05:40 -0500 Subject: [PATCH 16/33] Update macOS install docs for beta with unsigned binaries - Add note that macOS support is in beta with unsigned binaries - Document Homebrew Cask as primary install method - Add first-time security setup steps (Open Anyway, Input Monitoring) - Update config path to ~/Library/Application Support/voxtype - Document Right Option as default hotkey - Add troubleshooting for common issues - Simplify and focus on current workflow Co-Authored-By: Claude Opus 4.5 --- docs/INSTALL_MACOS.md | 244 +++++++++++++++++++----------------------- 1 file changed, 110 insertions(+), 134 deletions(-) diff --git a/docs/INSTALL_MACOS.md b/docs/INSTALL_MACOS.md index c9bf931e..55995ba8 100644 --- a/docs/INSTALL_MACOS.md +++ b/docs/INSTALL_MACOS.md @@ -1,213 +1,189 @@ # Voxtype macOS Installation Guide -Voxtype is a push-to-talk voice-to-text tool that uses Whisper for fast, local speech recognition. +Voxtype is a push-to-talk voice-to-text tool with fast, local speech recognition using Parakeet or Whisper. -## Requirements +> **Note:** macOS support is in beta. The binaries are currently unsigned, which requires a few extra steps during installation. Once we have signed and notarized binaries, this process will be simpler. -- macOS 11 (Big Sur) or later -- Apple Silicon (M1/M2/M3) or Intel Mac -- Accessibility permissions for global hotkey detection +## Requirements -## Installation +- macOS 13 (Ventura) or later +- Apple Silicon (M1/M2/M3/M4) +- Microphone access +- Input Monitoring permission (for global hotkey) -### Option 1: Homebrew (Recommended) +## Installation via Homebrew (Recommended) ```bash # Add the tap brew tap peteonrails/voxtype # Install -brew install --cask voxtype +brew install --cask peteonrails/voxtype/voxtype ``` -### Option 2: Direct Download - -1. Download the latest DMG from [GitHub Releases](https://github.com/peteonrails/voxtype/releases) -2. Open the DMG and drag `voxtype` to `/usr/local/bin` +The Cask automatically: +- Installs Voxtype.app to /Applications +- Creates CLI symlink (`voxtype` command) +- Sets up auto-start at login +- Starts the daemon -```bash -# Or install via command line -curl -L https://github.com/peteonrails/voxtype/releases/latest/download/voxtype-macos-universal.dmg -o voxtype.dmg -hdiutil attach voxtype.dmg -cp /Volumes/Voxtype/voxtype /usr/local/bin/ -hdiutil detach /Volumes/Voxtype -rm voxtype.dmg -``` +### First-Time Security Setup -### Option 3: Build from Source +Because the app is unsigned, macOS will block it on first run. This is a one-time setup: -```bash -git clone https://github.com/peteonrails/voxtype.git -cd voxtype -cargo build --release --features gpu-metal -cp target/release/voxtype /usr/local/bin/ -``` +1. **Allow the app to run:** + - Open **System Settings** > **Privacy & Security** + - Scroll down to find "Voxtype.app was blocked" + - Click **Open Anyway** -## Setup +2. **Grant Input Monitoring permission (required for hotkey):** + - Open **System Settings** > **Privacy & Security** > **Input Monitoring** + - Enable **Voxtype** -### 1. Grant Accessibility Permissions +3. **Restart the daemon** to pick up permissions: + ```bash + launchctl stop io.voxtype.daemon + launchctl start io.voxtype.daemon + ``` -Voxtype needs Accessibility permissions to detect global hotkeys. - -1. Open **System Preferences** (or System Settings on macOS 13+) -2. Go to **Privacy & Security** > **Accessibility** -3. Click the lock icon to make changes -4. Add and enable `voxtype` (or Terminal if running from terminal) - -### 2. Download a Whisper Model +### Download a Speech Model ```bash -# Interactive model selection -voxtype setup model +# Recommended: Parakeet (fast, accurate) +voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 -# Or download a specific model +# Or use Whisper voxtype setup --download --model base.en ``` -Available models: -- `tiny.en` / `tiny` - Fastest, lowest accuracy (39 MB) -- `base.en` / `base` - Good balance, recommended (142 MB) -- `small.en` / `small` - Better accuracy (466 MB) -- `medium.en` / `medium` - High accuracy (1.5 GB) -- `large-v3` - Best accuracy (3.1 GB) **Pro only** -- `large-v3-turbo` - Fast + accurate (1.6 GB) **Pro only** +## Usage -### 3. Configure Hotkey +Hold **Right Option** (⌥) to record, release to transcribe. Text is typed into the active application. -Edit `~/.config/voxtype/config.toml`: +### Quick Commands -```toml -[hotkey] -key = "F13" # Or any key: SCROLLLOCK, PAUSE, etc. -modifiers = [] # Optional: ["CTRL"], ["CMD"], etc. -mode = "push_to_talk" # Or "toggle" +```bash +voxtype status # Check daemon status +voxtype record start # Start recording manually +voxtype record stop # Stop and transcribe +voxtype setup check # Verify setup +voxtype menubar # Show menu bar status icon ``` -### 4. Start Voxtype +### Menu Bar Icon (Optional) -**Manual start:** -```bash -voxtype daemon -``` +For a status icon showing recording state: -**Auto-start on login (recommended):** ```bash -voxtype setup launchd +voxtype menubar ``` -## Usage +This shows: +- 🎙️ Ready (idle) +- 🔴 Recording +- ⏳ Transcribing -1. Hold the hotkey (default: F13) to record -2. Speak your text -3. Release the hotkey to transcribe -4. Text is typed into the active window +## Configuration -### Quick Reference +Config file: `~/Library/Application Support/voxtype/config.toml` -```bash -voxtype daemon # Start the daemon -voxtype status # Check if daemon is running -voxtype setup model # Download/switch models -voxtype setup launchd # Install as LaunchAgent -voxtype check-update # Check for updates -voxtype --help # Show all options +```toml +# Transcription engine +engine = "parakeet" # or "whisper" + +[hotkey] +key = "RIGHTALT" # Right Option key +mode = "push_to_talk" # or "toggle" + +[parakeet] +model = "parakeet-tdt-0.6b-v3-int8" + +[whisper] +model = "base.en" + +[output] +mode = "type" # or "clipboard", "paste" ``` +See [CONFIGURATION.md](CONFIGURATION.md) for full options. + ## Troubleshooting -### "Accessibility permissions required" +### Hotkey not working -1. Check System Preferences > Privacy & Security > Accessibility -2. Ensure voxtype (or Terminal) is added and enabled -3. Try removing and re-adding the app +1. Verify Input Monitoring permission is granted: + - System Settings > Privacy & Security > Input Monitoring + - Voxtype must be enabled -### Hotkey not working +2. Restart the daemon: + ```bash + launchctl stop io.voxtype.daemon + launchctl start io.voxtype.daemon + ``` -1. Check that the hotkey isn't used by another app -2. Try a different key (F13, SCROLLLOCK, PAUSE are good choices) -3. Ensure Accessibility permissions are granted +3. Check daemon logs: + ```bash + tail -f ~/Library/Logs/voxtype/stdout.log + ``` -### "Model not found" +### "Voxtype was blocked" / "damaged app" -```bash -voxtype setup model # Download a model -``` +This happens because the app is unsigned. Go to System Settings > Privacy & Security and click "Open Anyway". -### Daemon not starting +### Model not found ```bash -# Check logs -tail -f ~/Library/Logs/voxtype/stderr.log - -# Verify permissions -ls -la /usr/local/bin/voxtype +voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 ``` -### LaunchAgent issues +### Daemon not starting ```bash # Check status launchctl list | grep voxtype # View logs -tail -f ~/Library/Logs/voxtype/stdout.log +tail -f ~/Library/Logs/voxtype/stderr.log -# Reload service -launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist -launchctl load ~/Library/LaunchAgents/io.voxtype.daemon.plist +# Manual start for debugging +voxtype daemon ``` -## Uninstalling - -### Homebrew +### "Another instance is already running" ```bash -brew uninstall --cask voxtype +# Clean up stale state +pkill -9 voxtype +rm -rf /tmp/voxtype +launchctl start io.voxtype.daemon ``` -### Manual +## Uninstalling ```bash -# Stop and remove LaunchAgent -launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist -rm ~/Library/LaunchAgents/io.voxtype.daemon.plist +brew uninstall --cask voxtype +``` -# Remove binary -rm /usr/local/bin/voxtype +This removes: +- Voxtype.app from /Applications +- LaunchAgent (auto-start) +- CLI symlink -# Remove config and data (optional) -rm -rf ~/.config/voxtype -rm -rf ~/.local/share/voxtype +To also remove data: +```bash +rm -rf ~/Library/Application\ Support/voxtype rm -rf ~/Library/Logs/voxtype ``` -## Configuration - -Config file: `~/.config/voxtype/config.toml` +## Building from Source -```toml -[hotkey] -key = "F13" -modifiers = [] -mode = "push_to_talk" # or "toggle" - -[audio] -device = "default" -sample_rate = 16000 -max_duration_secs = 60 - -[whisper] -model = "base.en" -language = "en" - -[output] -mode = "type" # or "clipboard", "paste" +```bash +git clone https://github.com/peteonrails/voxtype.git +cd voxtype +cargo build --release --features parakeet ``` -See [CONFIGURATION.md](CONFIGURATION.md) for full options. - ## Getting Help - GitHub Issues: https://github.com/peteonrails/voxtype/issues -- Documentation: https://voxtype.io/docs -- Email: support@voxtype.io +- Documentation: https://voxtype.io From fa44ac8168c583cb4bd496169e11abbeccfef54d Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 19:11:11 -0500 Subject: [PATCH 17/33] Add VoxtypeMenubar Swift app for macOS status icon Separate Swift app for menu bar functionality: - Shows mic icon that changes based on daemon state - Dropdown menu with recording controls - Settings submenu (engine, output mode, hotkey mode) - Open setup, restart daemon, view logs actions - Reads state from /tmp/voxtype/state - Communicates with daemon via voxtype CLI This keeps macOS-specific GUI code out of the main Rust binary. Co-Authored-By: Claude Opus 4.5 --- macos/VoxtypeMenubar/.gitignore | 2 + macos/VoxtypeMenubar/Package.swift | 18 ++ .../VoxtypeMenubar/Sources/MenuBarView.swift | 183 ++++++++++++++++++ macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift | 71 +++++++ .../Sources/VoxtypeMenubarApp.swift | 17 ++ .../Sources/VoxtypeStatusMonitor.swift | 113 +++++++++++ macos/VoxtypeMenubar/build-app.sh | 64 ++++++ 7 files changed, 468 insertions(+) create mode 100644 macos/VoxtypeMenubar/.gitignore create mode 100644 macos/VoxtypeMenubar/Package.swift create mode 100644 macos/VoxtypeMenubar/Sources/MenuBarView.swift create mode 100644 macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift create mode 100644 macos/VoxtypeMenubar/Sources/VoxtypeMenubarApp.swift create mode 100644 macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift create mode 100755 macos/VoxtypeMenubar/build-app.sh diff --git a/macos/VoxtypeMenubar/.gitignore b/macos/VoxtypeMenubar/.gitignore new file mode 100644 index 00000000..2d9f16e2 --- /dev/null +++ b/macos/VoxtypeMenubar/.gitignore @@ -0,0 +1,2 @@ +.build/ +.swiftpm/ diff --git a/macos/VoxtypeMenubar/Package.swift b/macos/VoxtypeMenubar/Package.swift new file mode 100644 index 00000000..d8d938a8 --- /dev/null +++ b/macos/VoxtypeMenubar/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "VoxtypeMenubar", + platforms: [ + .macOS(.v13) + ], + products: [ + .executable(name: "VoxtypeMenubar", targets: ["VoxtypeMenubar"]) + ], + targets: [ + .executableTarget( + name: "VoxtypeMenubar", + path: "Sources" + ) + ] +) diff --git a/macos/VoxtypeMenubar/Sources/MenuBarView.swift b/macos/VoxtypeMenubar/Sources/MenuBarView.swift new file mode 100644 index 00000000..ab4b41d3 --- /dev/null +++ b/macos/VoxtypeMenubar/Sources/MenuBarView.swift @@ -0,0 +1,183 @@ +import SwiftUI + +struct MenuBarView: View { + @EnvironmentObject var statusMonitor: VoxtypeStatusMonitor + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Status section + HStack { + Image(systemName: statusMonitor.iconName) + .foregroundColor(statusColor) + Text(statusMonitor.statusText) + .fontWeight(.medium) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + + Divider() + + // Recording controls + Button(action: toggleRecording) { + Label("Toggle Recording", systemImage: "record.circle") + } + .keyboardShortcut("r", modifiers: []) + .disabled(!statusMonitor.daemonRunning) + + Button(action: cancelRecording) { + Label("Cancel Recording", systemImage: "xmark.circle") + } + .disabled(statusMonitor.state != .recording) + + Divider() + + // Settings submenu + Menu("Settings") { + Menu("Engine") { + Button("Parakeet (Fast)") { + setEngine("parakeet") + } + Button("Whisper") { + setEngine("whisper") + } + } + + Menu("Output Mode") { + Button("Type Text") { + setOutputMode("type") + } + Button("Clipboard") { + setOutputMode("clipboard") + } + Button("Clipboard + Paste") { + setOutputMode("paste") + } + } + + Menu("Hotkey Mode") { + Button("Push-to-Talk (hold)") { + setHotkeyMode("push_to_talk") + } + Button("Toggle (press)") { + setHotkeyMode("toggle") + } + } + } + + Divider() + + Button(action: openSetup) { + Label("Open Setup...", systemImage: "gearshape") + } + + Button(action: restartDaemon) { + Label("Restart Daemon", systemImage: "arrow.clockwise") + } + + Button(action: viewLogs) { + Label("View Logs", systemImage: "doc.text") + } + + Divider() + + Button(action: quitApp) { + Label("Quit Menu Bar", systemImage: "power") + } + .keyboardShortcut("q", modifiers: .command) + } + } + + private var statusColor: Color { + switch statusMonitor.state { + case .idle: + return .green + case .recording: + return .red + case .transcribing: + return .orange + case .stopped: + return .gray + } + } + + // MARK: - Actions + + private func toggleRecording() { + VoxtypeCLI.run(["record", "toggle"]) + } + + private func cancelRecording() { + VoxtypeCLI.run(["record", "cancel"]) + } + + private func setEngine(_ engine: String) { + // Update config file + updateConfig(key: "engine", value: "\"\(engine)\"", section: nil) + showNotification(title: "Voxtype", message: "Engine set to \(engine). Restart daemon to apply.") + } + + private func setOutputMode(_ mode: String) { + updateConfig(key: "mode", value: "\"\(mode)\"", section: "[output]") + } + + private func setHotkeyMode(_ mode: String) { + updateConfig(key: "mode", value: "\"\(mode)\"", section: "[hotkey]") + showNotification(title: "Voxtype", message: "Hotkey mode changed. Restart daemon to apply.") + } + + private func openSetup() { + // Try to open VoxtypeSetup from the app bundle + let setupPath = Bundle.main.bundlePath + .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/VoxtypeSetup") + + if FileManager.default.fileExists(atPath: setupPath) { + Process.launchedProcess(launchPath: setupPath, arguments: []) + } else { + // Fallback: open config file + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + NSWorkspace.shared.open(URL(fileURLWithPath: configPath)) + } + } + + private func restartDaemon() { + VoxtypeCLI.run(["daemon", "restart"], wait: false) + showNotification(title: "Voxtype", message: "Restarting daemon...") + } + + private func viewLogs() { + let logsPath = NSHomeDirectory() + "/Library/Logs/voxtype" + NSWorkspace.shared.open(URL(fileURLWithPath: logsPath)) + } + + private func quitApp() { + NSApplication.shared.terminate(nil) + } + + // MARK: - Helpers + + private func updateConfig(key: String, value: String, section: String?) { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + + guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + let pattern = "\(key)\\s*=\\s*\"[^\"]*\"" + let replacement = "\(key) = \(value)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(content.startIndex..., in: content) + content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) + } + + try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + } + + private func showNotification(title: String, message: String) { + let script = "display notification \"\(message)\" with title \"\(title)\"" + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + } + } +} diff --git a/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift b/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift new file mode 100644 index 00000000..cefb282c --- /dev/null +++ b/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Helper to run voxtype CLI commands +enum VoxtypeCLI { + /// Path to voxtype binary + static var binaryPath: String { + // First try the app bundle location + let bundlePath = Bundle.main.bundlePath + let appBundlePath = bundlePath + .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/voxtype") + + if FileManager.default.fileExists(atPath: appBundlePath) { + return appBundlePath + } + + // Try /Applications + let applicationsPath = "/Applications/Voxtype.app/Contents/MacOS/voxtype" + if FileManager.default.fileExists(atPath: applicationsPath) { + return applicationsPath + } + + // Try homebrew symlink + let homebrewPath = "/opt/homebrew/bin/voxtype" + if FileManager.default.fileExists(atPath: homebrewPath) { + return homebrewPath + } + + // Fallback to PATH + return "voxtype" + } + + /// Run a voxtype command + @discardableResult + static func run(_ arguments: [String], wait: Bool = true) -> (output: String, success: Bool) { + let task = Process() + task.launchPath = binaryPath + task.arguments = arguments + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + + if wait { + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, task.terminationStatus == 0) + } else { + return ("", true) + } + } catch { + return ("Error: \(error.localizedDescription)", false) + } + } + + /// Get daemon status + static func getStatus() -> String { + let result = run(["status"]) + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Check if daemon is running + static func isDaemonRunning() -> Bool { + let result = run(["status"]) + let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "idle" || status == "recording" || status == "transcribing" + } +} diff --git a/macos/VoxtypeMenubar/Sources/VoxtypeMenubarApp.swift b/macos/VoxtypeMenubar/Sources/VoxtypeMenubarApp.swift new file mode 100644 index 00000000..b5ef4cea --- /dev/null +++ b/macos/VoxtypeMenubar/Sources/VoxtypeMenubarApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct VoxtypeMenubarApp: App { + @StateObject private var statusMonitor = VoxtypeStatusMonitor() + + var body: some Scene { + MenuBarExtra { + MenuBarView() + .environmentObject(statusMonitor) + } label: { + Image(systemName: statusMonitor.iconName) + .symbolRenderingMode(.hierarchical) + } + .menuBarExtraStyle(.menu) + } +} diff --git a/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift b/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift new file mode 100644 index 00000000..d81fd9e1 --- /dev/null +++ b/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift @@ -0,0 +1,113 @@ +import Foundation +import Combine + +/// Monitors voxtype daemon state by watching the state file +class VoxtypeStatusMonitor: ObservableObject { + @Published var state: VoxtypeState = .stopped + @Published var daemonRunning: Bool = false + + private var timer: Timer? + private let stateFilePath = "/tmp/voxtype/state" + + var iconName: String { + switch state { + case .idle: + return "mic.fill" + case .recording: + return "mic.badge.plus" + case .transcribing: + return "ellipsis.circle.fill" + case .stopped: + return "mic.slash.fill" + } + } + + var statusText: String { + switch state { + case .idle: + return "Ready" + case .recording: + return "Recording..." + case .transcribing: + return "Transcribing..." + case .stopped: + return "Daemon not running" + } + } + + init() { + startMonitoring() + } + + deinit { + stopMonitoring() + } + + func startMonitoring() { + // Check immediately + updateState() + + // Then poll every 500ms + timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in + self?.updateState() + } + } + + func stopMonitoring() { + timer?.invalidate() + timer = nil + } + + private func updateState() { + // Check if daemon is running + daemonRunning = isDaemonRunning() + + if !daemonRunning { + state = .stopped + return + } + + // Read state file + guard let content = try? String(contentsOfFile: stateFilePath, encoding: .utf8) else { + state = .stopped + return + } + + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch trimmed { + case "idle": + state = .idle + case "recording": + state = .recording + case "transcribing": + state = .transcribing + default: + state = .stopped + } + } + + private func isDaemonRunning() -> Bool { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["list", "io.voxtype.daemon"] + + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + return false + } + } +} + +enum VoxtypeState { + case idle + case recording + case transcribing + case stopped +} diff --git a/macos/VoxtypeMenubar/build-app.sh b/macos/VoxtypeMenubar/build-app.sh new file mode 100755 index 00000000..02c7bafc --- /dev/null +++ b/macos/VoxtypeMenubar/build-app.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# Build VoxtypeMenubar.app bundle + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +# Build release +swift build -c release + +# Create app bundle structure +APP_NAME="VoxtypeMenubar" +APP_BUNDLE="$SCRIPT_DIR/.build/${APP_NAME}.app" +CONTENTS="$APP_BUNDLE/Contents" +MACOS="$CONTENTS/MacOS" +RESOURCES="$CONTENTS/Resources" + +rm -rf "$APP_BUNDLE" +mkdir -p "$MACOS" "$RESOURCES" + +# Copy binary +cp ".build/release/$APP_NAME" "$MACOS/" + +# Create Info.plist +cat > "$CONTENTS/Info.plist" << 'EOF' + + + + + CFBundleExecutable + VoxtypeMenubar + CFBundleIdentifier + io.voxtype.menubar + CFBundleName + Voxtype Menu Bar + CFBundleDisplayName + Voxtype Menu Bar + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSMinimumSystemVersion + 13.0 + LSUIElement + + NSHighResolutionCapable + + + +EOF + +# Sign the app +codesign --force --deep --sign - "$APP_BUNDLE" + +echo "Built: $APP_BUNDLE" +echo "" +echo "To install to app bundle:" +echo " cp -r $APP_BUNDLE /Applications/Voxtype.app/Contents/MacOS/" +echo "" +echo "To run:" +echo " open $APP_BUNDLE" From ab9a350ed2357f372838df25f6529f5f0f9b1d7f Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 19:14:28 -0500 Subject: [PATCH 18/33] Improve VoxtypeMenubar menu layout and settings launch - Add "Voxtype" header at top of menu - Change "Open Setup..." to "Settings..." that launches VoxtypeSetup app - Search multiple locations for VoxtypeSetup - Change "Quit Menu Bar" to "Quit Voxtype Menu Bar" Co-Authored-By: Claude Opus 4.5 --- .../VoxtypeMenubar/Sources/MenuBarView.swift | 59 ++++++++++++++----- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/macos/VoxtypeMenubar/Sources/MenuBarView.swift b/macos/VoxtypeMenubar/Sources/MenuBarView.swift index ab4b41d3..7a734773 100644 --- a/macos/VoxtypeMenubar/Sources/MenuBarView.swift +++ b/macos/VoxtypeMenubar/Sources/MenuBarView.swift @@ -5,15 +5,21 @@ struct MenuBarView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { + // Header + Text("Voxtype") + .font(.headline) + .padding(.horizontal, 12) + .padding(.top, 8) + .padding(.bottom, 4) + // Status section HStack { Image(systemName: statusMonitor.iconName) .foregroundColor(statusColor) Text(statusMonitor.statusText) - .fontWeight(.medium) } .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.bottom, 8) Divider() @@ -66,8 +72,8 @@ struct MenuBarView: View { Divider() - Button(action: openSetup) { - Label("Open Setup...", systemImage: "gearshape") + Button(action: openSettings) { + Label("Settings...", systemImage: "gearshape") } Button(action: restartDaemon) { @@ -81,7 +87,7 @@ struct MenuBarView: View { Divider() Button(action: quitApp) { - Label("Quit Menu Bar", systemImage: "power") + Label("Quit Voxtype Menu Bar", systemImage: "power") } .keyboardShortcut("q", modifiers: .command) } @@ -125,18 +131,39 @@ struct MenuBarView: View { showNotification(title: "Voxtype", message: "Hotkey mode changed. Restart daemon to apply.") } - private func openSetup() { - // Try to open VoxtypeSetup from the app bundle - let setupPath = Bundle.main.bundlePath - .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/VoxtypeSetup") - - if FileManager.default.fileExists(atPath: setupPath) { - Process.launchedProcess(launchPath: setupPath, arguments: []) - } else { - // Fallback: open config file - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - NSWorkspace.shared.open(URL(fileURLWithPath: configPath)) + private func openSettings() { + // Try multiple locations for VoxtypeSetup + let possiblePaths = [ + // Inside main app bundle + "/Applications/Voxtype.app/Contents/MacOS/VoxtypeSetup", + // Standalone app in Applications + "/Applications/VoxtypeSetup.app", + // Next to this menubar app + Bundle.main.bundlePath.replacingOccurrences(of: "VoxtypeMenubar.app", with: "VoxtypeSetup.app"), + ] + + for path in possiblePaths { + if path.hasSuffix(".app") { + // It's an app bundle + if FileManager.default.fileExists(atPath: path) { + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + return + } + } else { + // It's a binary + if FileManager.default.fileExists(atPath: path) { + do { + try Process.run(URL(fileURLWithPath: path), arguments: []) + return + } catch { + continue + } + } + } } + + // Fallback: show notification that settings app not found + showNotification(title: "Voxtype", message: "Settings app not found. Edit config at ~/Library/Application Support/voxtype/config.toml") } private func restartDaemon() { From 3f1e3070fc6c03b9fbb8bc1f6589df9ba01da9ca Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Fri, 30 Jan 2026 19:22:45 -0500 Subject: [PATCH 19/33] Replace VoxtypeSetup wizard with sidebar settings app Redesigned VoxtypeSetup from a sequential wizard to a proper macOS settings app with sidebar navigation: - General: Engine selection, hotkey config, daemon status - Models: View installed models, download new ones - Output: Output mode, typing delay, auto-submit - Permissions: Check and grant required permissions - Advanced: Open config file, logs, auto-start toggle Removed old wizard files (WelcomeView, SetupWizardView, etc.) in favor of non-sequential, easy-to-navigate settings panels. Co-Authored-By: Claude Opus 4.5 --- .../Sources/Preferences/PreferencesView.swift | 347 ----------------- .../Settings/AdvancedSettingsView.swift | 173 +++++++++ .../Settings/GeneralSettingsView.swift | 165 +++++++++ .../Sources/Settings/ModelsSettingsView.swift | 267 +++++++++++++ .../Sources/Settings/OutputSettingsView.swift | 142 +++++++ .../Settings/PermissionsSettingsView.swift | 119 ++++++ .../Sources/SetupWizard/CompleteView.swift | 116 ------ .../Sources/SetupWizard/LaunchAgentView.swift | 144 ------- .../SetupWizard/ModelSelectionView.swift | 350 ------------------ .../Sources/SetupWizard/PermissionsView.swift | 235 ------------ .../Sources/SetupWizard/SetupWizardView.swift | 82 ---- .../Sources/SetupWizard/WelcomeView.swift | 84 ----- .../Sources/Utilities/VoxtypeCLI.swift | 313 +++------------- .../Sources/VoxtypeSetupApp.swift | 93 +++-- 14 files changed, 968 insertions(+), 1662 deletions(-) delete mode 100644 macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/PermissionsSettingsView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift delete mode 100644 macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift diff --git a/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift b/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift deleted file mode 100644 index 62fd5702..00000000 --- a/macos/VoxtypeSetup/Sources/Preferences/PreferencesView.swift +++ /dev/null @@ -1,347 +0,0 @@ -import SwiftUI - -struct PreferencesView: View { - @EnvironmentObject var setupState: SetupState - @StateObject private var permissions = PermissionChecker.shared - - @State private var selectedEngine: TranscriptionEngine = .parakeet - @State private var selectedModel: String = "" - @State private var autoStartEnabled: Bool = false - @State private var showingModelDownload = false - @State private var daemonStatus: String = "" - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Image(systemName: "mic.circle.fill") - .font(.title) - .foregroundColor(.accentColor) - Text("Voxtype Preferences") - .font(.title2) - .fontWeight(.semibold) - Spacer() - - // Status indicator - HStack(spacing: 6) { - Circle() - .fill(daemonRunning ? Color.green : Color.red) - .frame(width: 8, height: 8) - Text(daemonRunning ? "Running" : "Stopped") - .font(.callout) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - - Divider() - - ScrollView { - VStack(spacing: 24) { - // Engine & Model - PreferenceSection(title: "Speech Recognition", icon: "waveform") { - VStack(alignment: .leading, spacing: 12) { - Text("Engine") - .font(.callout) - .foregroundColor(.secondary) - - Picker("Engine", selection: $selectedEngine) { - Text("Parakeet").tag(TranscriptionEngine.parakeet) - Text("Whisper").tag(TranscriptionEngine.whisper) - } - .pickerStyle(.segmented) - .onChange(of: selectedEngine) { _ in - _ = VoxtypeCLI.shared.setEngine(selectedEngine) - } - - Text("Model") - .font(.callout) - .foregroundColor(.secondary) - .padding(.top, 8) - - HStack { - Text(selectedModel.isEmpty ? "No model selected" : selectedModel) - .foregroundColor(selectedModel.isEmpty ? .secondary : .primary) - Spacer() - Button("Change...") { - showingModelDownload = true - } - } - } - } - - // Permissions - PreferenceSection(title: "Permissions", icon: "lock.shield") { - VStack(spacing: 12) { - PermissionStatusRow( - title: "Microphone", - isGranted: permissions.hasMicrophoneAccess, - action: { permissions.openMicrophoneSettings() } - ) - PermissionStatusRow( - title: "Accessibility", - isGranted: permissions.hasAccessibilityAccess, - action: { permissions.openAccessibilitySettings() } - ) - PermissionStatusRow( - title: "Input Monitoring", - isGranted: permissions.hasInputMonitoringAccess, - action: { permissions.openInputMonitoringSettings() } - ) - } - } - - // Auto-start - PreferenceSection(title: "Startup", icon: "arrow.clockwise") { - Toggle(isOn: $autoStartEnabled) { - Text("Start Voxtype at login") - } - .onChange(of: autoStartEnabled) { newValue in - if newValue { - _ = VoxtypeCLI.shared.installLaunchAgent() - } else { - _ = VoxtypeCLI.shared.uninstallLaunchAgent() - } - } - } - - // Daemon control - PreferenceSection(title: "Daemon", icon: "gearshape.2") { - HStack { - Button("Restart Daemon") { - VoxtypeCLI.shared.restartDaemon() - } - - Button("Stop Daemon") { - VoxtypeCLI.shared.stopDaemon() - } - - Spacer() - - Button("View Logs") { - let logPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Logs/voxtype") - NSWorkspace.shared.open(logPath) - } - } - } - - // Config file - PreferenceSection(title: "Advanced", icon: "doc.text") { - HStack { - Text("For advanced settings, edit the config file directly") - .font(.callout) - .foregroundColor(.secondary) - Spacer() - Button("Open Config") { - let configPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/voxtype/config.toml") - NSWorkspace.shared.open(configPath) - } - } - } - } - .padding() - } - - Divider() - - // Footer - HStack { - Button("Run Setup Again") { - setupState.resetWizard() - } - .buttonStyle(.borderless) - - Spacer() - - Button("Quit") { - NSApplication.shared.terminate(nil) - } - } - .padding() - } - .frame(width: 500, height: 600) - .onAppear { - loadCurrentSettings() - } - .sheet(isPresented: $showingModelDownload) { - ModelDownloadSheet(selectedEngine: selectedEngine) { model in - selectedModel = model - } - } - } - - var daemonRunning: Bool { - !daemonStatus.isEmpty && daemonStatus != "stopped" - } - - func loadCurrentSettings() { - let cli = VoxtypeCLI.shared - autoStartEnabled = cli.hasLaunchAgent() - daemonStatus = cli.getStatus() - permissions.refresh() - - // Load current engine and model from config - if let config = cli.getConfig() { - if let engine = config["engine"] as? String { - selectedEngine = engine == "parakeet" ? .parakeet : .whisper - } - // TODO: Extract current model from config - } - } -} - -struct PreferenceSection: View { - let title: String - let icon: String - let content: () -> Content - - init(title: String, icon: String, @ViewBuilder content: @escaping () -> Content) { - self.title = title - self.icon = icon - self.content = content - } - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Image(systemName: icon) - .foregroundColor(.accentColor) - Text(title) - .fontWeight(.semibold) - } - - content() - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) - } - } -} - -struct PermissionStatusRow: View { - let title: String - let isGranted: Bool - let action: () -> Void - - var body: some View { - HStack { - Text(title) - Spacer() - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - } else { - Button("Grant") { - action() - } - .controlSize(.small) - } - } - } -} - -struct ModelDownloadSheet: View { - let selectedEngine: TranscriptionEngine - let onSelect: (String) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var selectedModel: String = "" - @State private var isDownloading = false - - // Static model lists - private let parakeetModels: [ModelInfo] = [ - ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, - description: "Fast, optimized for Apple Silicon", size: "670 MB"), - ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, - description: "Full precision", size: "1.2 GB"), - ] - - private let whisperModels: [ModelInfo] = [ - ModelInfo(name: "large-v3-turbo", engine: .whisper, - description: "Best accuracy, multilingual", size: "1.6 GB"), - ModelInfo(name: "medium.en", engine: .whisper, - description: "Good accuracy, English only", size: "1.5 GB"), - ModelInfo(name: "small.en", engine: .whisper, - description: "Balanced speed/accuracy", size: "500 MB"), - ModelInfo(name: "base.en", engine: .whisper, - description: "Fast, English only", size: "145 MB"), - ModelInfo(name: "tiny.en", engine: .whisper, - description: "Fastest, lower accuracy", size: "75 MB"), - ] - - private var models: [ModelInfo] { - selectedEngine == .parakeet ? parakeetModels : whisperModels - } - - var body: some View { - VStack(spacing: 20) { - Text("Select Model") - .font(.title2) - .fontWeight(.semibold) - - List(selection: $selectedModel) { - ForEach(models) { model in - HStack { - VStack(alignment: .leading) { - Text(model.name) - .fontWeight(.medium) - Text(model.description) - .font(.callout) - .foregroundColor(.secondary) - } - Spacer() - Text(model.size) - .foregroundColor(.secondary) - } - .tag(model.name) - } - } - .frame(height: 200) - - if isDownloading { - ProgressView() - .progressViewStyle(.linear) - } - - HStack { - Button("Cancel") { - dismiss() - } - - Spacer() - - Button("Download & Use") { - downloadAndUse() - } - .disabled(selectedModel.isEmpty || isDownloading) - } - } - .padding() - .frame(width: 400) - } - - func downloadAndUse() { - isDownloading = true - Task { - do { - let cli = VoxtypeCLI.shared - try await cli.downloadModel(selectedModel, engine: selectedEngine) - _ = cli.setModel(selectedModel, engine: selectedEngine) - await MainActor.run { - onSelect(selectedModel) - dismiss() - } - } catch { - isDownloading = false - } - } - } -} - -#Preview { - PreferencesView() - .environmentObject(SetupState()) -} diff --git a/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift new file mode 100644 index 00000000..4480576c --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct AdvancedSettingsView: View { + @State private var autoStartEnabled: Bool = false + + var body: some View { + Form { + Section { + HStack { + VStack(alignment: .leading) { + Text("Configuration File") + Text("~/Library/Application Support/voxtype/config.toml") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Open in Editor") { + openConfigFile() + } + } + + HStack { + VStack(alignment: .leading) { + Text("Log Files") + Text("~/Library/Logs/voxtype/") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Open Folder") { + openLogsFolder() + } + } + + HStack { + VStack(alignment: .leading) { + Text("Models Folder") + Text("~/Library/Application Support/voxtype/models/") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Open Folder") { + openModelsFolder() + } + } + } header: { + Text("Files & Folders") + } + + Section { + Toggle("Start Voxtype at login", isOn: $autoStartEnabled) + .onChange(of: autoStartEnabled) { newValue in + toggleAutoStart(enabled: newValue) + } + + Text("Runs the Voxtype daemon automatically when you log in.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Auto-Start") + } + + Section { + Button(action: restartDaemon) { + Label("Restart Daemon", systemImage: "arrow.clockwise") + } + + Button(action: stopDaemon) { + Label("Stop Daemon", systemImage: "stop.fill") + } + .foregroundColor(.red) + + Button(action: runSetupCheck) { + Label("Run Setup Check", systemImage: "checkmark.circle") + } + } header: { + Text("Daemon") + } + + Section { + HStack { + Text("Version") + Spacer() + Text(getVersion()) + .foregroundColor(.secondary) + } + + Link(destination: URL(string: "https://github.com/peteonrails/voxtype")!) { + Label("View on GitHub", systemImage: "link") + } + + Link(destination: URL(string: "https://voxtype.io")!) { + Label("Documentation", systemImage: "book") + } + } header: { + Text("About") + } + } + .formStyle(.grouped) + .onAppear { + checkAutoStartStatus() + } + } + + private func openConfigFile() { + let path = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + + private func openLogsFolder() { + let path = NSHomeDirectory() + "/Library/Logs/voxtype" + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + + private func openModelsFolder() { + let path = NSHomeDirectory() + "/Library/Application Support/voxtype/models" + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + } + + private func checkAutoStartStatus() { + let plistPath = NSHomeDirectory() + "/Library/LaunchAgents/io.voxtype.daemon.plist" + autoStartEnabled = FileManager.default.fileExists(atPath: plistPath) + } + + private func toggleAutoStart(enabled: Bool) { + if enabled { + VoxtypeCLI.run(["setup", "launchd"]) + } else { + VoxtypeCLI.run(["setup", "launchd", "--uninstall"]) + } + } + + private func restartDaemon() { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["kickstart", "-k", "gui/\(getuid())/io.voxtype.daemon"] + try? task.run() + } + + private func stopDaemon() { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["stop", "io.voxtype.daemon"] + try? task.run() + } + + private func runSetupCheck() { + // Open Terminal with setup check command + let voxtype = VoxtypeCLI.binaryPath + let script = """ + tell application "Terminal" + do script "\(voxtype) setup check" + activate + end tell + """ + if let appleScript = NSAppleScript(source: script) { + var error: NSDictionary? + appleScript.executeAndReturnError(&error) + } + } + + private func getVersion() -> String { + let result = VoxtypeCLI.run(["--version"]) + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift new file mode 100644 index 00000000..1b810e6f --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift @@ -0,0 +1,165 @@ +import SwiftUI + +struct GeneralSettingsView: View { + @State private var selectedEngine: String = "parakeet" + @State private var hotkeyMode: String = "push_to_talk" + @State private var hotkey: String = "RIGHTALT" + @State private var daemonRunning: Bool = false + + var body: some View { + Form { + Section { + Picker("Transcription Engine", selection: $selectedEngine) { + Text("Parakeet (Fast)").tag("parakeet") + Text("Whisper").tag("whisper") + } + .onChange(of: selectedEngine) { newValue in + updateConfig(key: "engine", value: "\"\(newValue)\"") + } + + Text("Parakeet is faster and recommended for most users.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Engine") + } + + Section { + Picker("Hotkey", selection: $hotkey) { + Text("Right Option (⌥)").tag("RIGHTALT") + Text("Right Command (⌘)").tag("RIGHTMETA") + Text("Right Control (⌃)").tag("RIGHTCTRL") + Text("F13").tag("F13") + Text("F14").tag("F14") + Text("F15").tag("F15") + } + .onChange(of: hotkey) { newValue in + updateConfig(key: "key", value: "\"\(newValue)\"", section: "[hotkey]") + } + + Picker("Mode", selection: $hotkeyMode) { + Text("Push-to-Talk (hold to record)").tag("push_to_talk") + Text("Toggle (press to start/stop)").tag("toggle") + } + .onChange(of: hotkeyMode) { newValue in + updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[hotkey]") + } + } header: { + Text("Hotkey") + } + + Section { + HStack { + Circle() + .fill(daemonRunning ? Color.green : Color.red) + .frame(width: 10, height: 10) + Text(daemonRunning ? "Daemon is running" : "Daemon is not running") + + Spacer() + + if daemonRunning { + Button("Restart") { + restartDaemon() + } + } else { + Button("Start") { + startDaemon() + } + } + } + } header: { + Text("Daemon Status") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + checkDaemonStatus() + } + } + + private func loadSettings() { + let config = readConfig() + + if let engine = config["engine"] { + selectedEngine = engine.replacingOccurrences(of: "\"", with: "") + } + + if let key = config["hotkey.key"] { + hotkey = key.replacingOccurrences(of: "\"", with: "") + } + + if let mode = config["hotkey.mode"] { + hotkeyMode = mode.replacingOccurrences(of: "\"", with: "") + } + } + + private func checkDaemonStatus() { + let result = VoxtypeCLI.run(["status"]) + let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + daemonRunning = (status == "idle" || status == "recording" || status == "transcribing") + } + + private func startDaemon() { + VoxtypeCLI.run(["daemon"], wait: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + checkDaemonStatus() + } + } + + private func restartDaemon() { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["kickstart", "-k", "gui/\(getuid())/io.voxtype.daemon"] + try? task.run() + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + checkDaemonStatus() + } + } + + private func readConfig() -> [String: String] { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return [:] + } + + var result: [String: String] = [:] + var currentSection = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + currentSection = String(trimmed.dropFirst().dropLast()) + } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) + let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" + result[fullKey] = value + } + } + } + + return result + } + + private func updateConfig(key: String, value: String, section: String? = nil) { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + let pattern = "\(key)\\s*=\\s*\"[^\"]*\"" + let replacement = "\(key) = \(value)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(content.startIndex..., in: content) + content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) + } + + try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift new file mode 100644 index 00000000..120fbd5c --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift @@ -0,0 +1,267 @@ +import SwiftUI + +struct ModelsSettingsView: View { + @State private var installedModels: [ModelInfo] = [] + @State private var selectedModel: String = "" + @State private var isDownloading: Bool = false + @State private var downloadProgress: String = "" + + var body: some View { + Form { + Section { + if installedModels.isEmpty { + Text("No models installed") + .foregroundColor(.secondary) + } else { + ForEach(installedModels, id: \.name) { model in + HStack { + VStack(alignment: .leading) { + Text(model.name) + .fontWeight(model.name == selectedModel ? .semibold : .regular) + Text(model.size) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if model.name == selectedModel { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Button("Select") { + selectModel(model.name) + } + } + } + .padding(.vertical, 4) + } + } + } header: { + Text("Installed Models") + } + + Section { + VStack(alignment: .leading, spacing: 12) { + Text("Parakeet (Recommended)") + .font(.headline) + + HStack { + Button("Download parakeet-tdt-0.6b-v3-int8") { + downloadModel("parakeet-tdt-0.6b-v3-int8") + } + .disabled(isDownloading) + + Text("~640 MB") + .foregroundColor(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Whisper Models") + .font(.headline) + + HStack { + Button("Download base.en") { + downloadModel("base.en") + } + .disabled(isDownloading) + + Text("~142 MB - Good balance") + .foregroundColor(.secondary) + } + + HStack { + Button("Download small.en") { + downloadModel("small.en") + } + .disabled(isDownloading) + + Text("~466 MB - Better accuracy") + .foregroundColor(.secondary) + } + + HStack { + Button("Download large-v3-turbo") { + downloadModel("large-v3-turbo") + } + .disabled(isDownloading) + + Text("~1.6 GB - Best quality") + .foregroundColor(.secondary) + } + } + + if isDownloading { + HStack { + ProgressView() + .scaleEffect(0.8) + Text(downloadProgress) + .foregroundColor(.secondary) + } + } + } header: { + Text("Download Models") + } + } + .formStyle(.grouped) + .onAppear { + loadInstalledModels() + } + } + + private func loadInstalledModels() { + let modelsDir = NSHomeDirectory() + "/Library/Application Support/voxtype/models" + + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: modelsDir) else { + return + } + + var models: [ModelInfo] = [] + + for item in contents { + let path = modelsDir + "/" + item + + var isDir: ObjCBool = false + FileManager.default.fileExists(atPath: path, isDirectory: &isDir) + + if isDir.boolValue && item.contains("parakeet") { + // Parakeet model directory + let size = getDirectorySize(path) + models.append(ModelInfo(name: item, size: formatSize(size), isParakeet: true)) + } else if item.hasPrefix("ggml-") && item.hasSuffix(".bin") { + // Whisper model file + if let attrs = try? FileManager.default.attributesOfItem(atPath: path), + let size = attrs[.size] as? Int64 { + let modelName = item + .replacingOccurrences(of: "ggml-", with: "") + .replacingOccurrences(of: ".bin", with: "") + models.append(ModelInfo(name: modelName, size: formatSize(size), isParakeet: false)) + } + } + } + + installedModels = models + + // Get currently selected model from config + let config = readConfig() + if let engine = config["engine"]?.replacingOccurrences(of: "\"", with: ""), + engine == "parakeet" { + if let model = config["parakeet.model"]?.replacingOccurrences(of: "\"", with: "") { + selectedModel = model + } + } else { + if let model = config["whisper.model"]?.replacingOccurrences(of: "\"", with: "") { + selectedModel = model + } + } + } + + private func selectModel(_ name: String) { + let isParakeet = name.contains("parakeet") + + if isParakeet { + updateConfig(key: "engine", value: "\"parakeet\"") + updateConfig(key: "model", value: "\"\(name)\"", section: "[parakeet]") + } else { + updateConfig(key: "engine", value: "\"whisper\"") + updateConfig(key: "model", value: "\"\(name)\"", section: "[whisper]") + } + + selectedModel = name + } + + private func downloadModel(_ name: String) { + isDownloading = true + downloadProgress = "Downloading \(name)..." + + DispatchQueue.global().async { + let result = VoxtypeCLI.run(["setup", "--download", "--model", name]) + + DispatchQueue.main.async { + isDownloading = false + downloadProgress = "" + loadInstalledModels() + + if result.success { + selectModel(name) + } + } + } + } + + private func getDirectorySize(_ path: String) -> Int64 { + var size: Int64 = 0 + if let enumerator = FileManager.default.enumerator(atPath: path) { + while let file = enumerator.nextObject() as? String { + let filePath = path + "/" + file + if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath), + let fileSize = attrs[.size] as? Int64 { + size += fileSize + } + } + } + return size + } + + private func formatSize(_ bytes: Int64) -> String { + let mb = Double(bytes) / 1_000_000 + if mb >= 1000 { + return String(format: "%.1f GB", mb / 1000) + } + return String(format: "%.0f MB", mb) + } + + private func readConfig() -> [String: String] { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return [:] + } + + var result: [String: String] = [:] + var currentSection = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + currentSection = String(trimmed.dropFirst().dropLast()) + } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) + let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" + result[fullKey] = value + } + } + } + + return result + } + + private func updateConfig(key: String, value: String, section: String? = nil) { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + let pattern = "\(key)\\s*=\\s*\"[^\"]*\"" + let replacement = "\(key) = \(value)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(content.startIndex..., in: content) + content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) + } + + try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + } +} + +struct ModelInfo { + let name: String + let size: String + let isParakeet: Bool +} diff --git a/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift new file mode 100644 index 00000000..5d75fc96 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct OutputSettingsView: View { + @State private var outputMode: String = "type" + @State private var fallbackToClipboard: Bool = true + @State private var typeDelayMs: Int = 0 + @State private var autoSubmit: Bool = false + + var body: some View { + Form { + Section { + Picker("Output Mode", selection: $outputMode) { + Text("Type Text").tag("type") + Text("Copy to Clipboard").tag("clipboard") + Text("Clipboard + Paste").tag("paste") + } + .onChange(of: outputMode) { newValue in + updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[output]") + } + + Text(outputModeDescription) + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Output Mode") + } + + Section { + Toggle("Fall back to clipboard if typing fails", isOn: $fallbackToClipboard) + .onChange(of: fallbackToClipboard) { newValue in + updateConfig(key: "fallback_to_clipboard", value: newValue ? "true" : "false", section: "[output]") + } + + Stepper("Type delay: \(typeDelayMs) ms", value: $typeDelayMs, in: 0...100, step: 5) + .onChange(of: typeDelayMs) { newValue in + updateConfig(key: "type_delay_ms", value: "\(newValue)", section: "[output]") + } + + Text("Increase delay if characters are being dropped.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Typing Options") + } + + Section { + Toggle("Auto-submit after transcription", isOn: $autoSubmit) + .onChange(of: autoSubmit) { newValue in + updateConfig(key: "auto_submit", value: newValue ? "true" : "false", section: "[output]") + } + + Text("Press Enter automatically after typing transcribed text.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Behavior") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private var outputModeDescription: String { + switch outputMode { + case "type": + return "Text is typed directly into the active application." + case "clipboard": + return "Text is copied to clipboard. Paste manually with ⌘V." + case "paste": + return "Text is copied to clipboard and pasted automatically." + default: + return "" + } + } + + private func loadSettings() { + let config = readConfig() + + if let mode = config["output.mode"]?.replacingOccurrences(of: "\"", with: "") { + outputMode = mode + } + + if let fallback = config["output.fallback_to_clipboard"] { + fallbackToClipboard = fallback == "true" + } + + if let delay = config["output.type_delay_ms"], let value = Int(delay) { + typeDelayMs = value + } + + if let submit = config["output.auto_submit"] { + autoSubmit = submit == "true" + } + } + + private func readConfig() -> [String: String] { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return [:] + } + + var result: [String: String] = [:] + var currentSection = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + currentSection = String(trimmed.dropFirst().dropLast()) + } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) + let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" + result[fullKey] = value + } + } + } + + return result + } + + private func updateConfig(key: String, value: String, section: String? = nil) { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + let pattern = "\(key)\\s*=\\s*[^\\n]*" + let replacement = "\(key) = \(value)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: []) { + let range = NSRange(content.startIndex..., in: content) + content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) + } + + try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/PermissionsSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/PermissionsSettingsView.swift new file mode 100644 index 00000000..69eeb7ac --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/PermissionsSettingsView.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct PermissionsSettingsView: View { + @State private var microphoneGranted: Bool = false + @State private var inputMonitoringGranted: Bool = false + @State private var accessibilityGranted: Bool = false + + var body: some View { + Form { + Section { + PermissionRow( + title: "Microphone", + description: "Required to capture your voice for transcription", + icon: "mic.fill", + isGranted: microphoneGranted + ) { + openSystemPreferences("Privacy_Microphone") + } + + PermissionRow( + title: "Input Monitoring", + description: "Required for global hotkey detection", + icon: "keyboard", + isGranted: inputMonitoringGranted + ) { + openSystemPreferences("Privacy_ListenEvent") + } + + PermissionRow( + title: "Accessibility", + description: "Required to type transcribed text into applications", + icon: "hand.raised.fill", + isGranted: accessibilityGranted + ) { + openSystemPreferences("Privacy_Accessibility") + } + } header: { + Text("Required Permissions") + } footer: { + Text("Click \"Open Settings\" to grant each permission. You may need to add Voxtype manually.") + } + + Section { + Button(action: checkPermissions) { + Label("Refresh Permission Status", systemImage: "arrow.clockwise") + } + } + } + .formStyle(.grouped) + .onAppear { + checkPermissions() + } + } + + private func checkPermissions() { + // Check microphone permission + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized: + microphoneGranted = true + default: + microphoneGranted = false + } + + // Input monitoring and accessibility are harder to check programmatically + // We use a heuristic: try to see if voxtype status works + let result = VoxtypeCLI.run(["status"]) + let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + // If daemon is running and responding, permissions are likely granted + if status == "idle" || status == "recording" || status == "transcribing" { + inputMonitoringGranted = true + accessibilityGranted = true + } + } + + private func openSystemPreferences(_ pane: String) { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)")! + NSWorkspace.shared.open(url) + } +} + +struct PermissionRow: View { + let title: String + let description: String + let icon: String + let isGranted: Bool + let openSettings: () -> Void + + var body: some View { + HStack { + Image(systemName: icon) + .frame(width: 24) + .foregroundColor(.accentColor) + + VStack(alignment: .leading) { + Text(title) + .fontWeight(.medium) + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if isGranted { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Button("Open Settings") { + openSettings() + } + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } +} + +import AVFoundation diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift deleted file mode 100644 index 5908e50e..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/CompleteView.swift +++ /dev/null @@ -1,116 +0,0 @@ -import SwiftUI - -struct CompleteView: View { - @EnvironmentObject var setupState: SetupState - - var body: some View { - VStack(spacing: 16) { - Spacer(minLength: 10) - - Image(systemName: "checkmark.circle.fill") - .resizable() - .frame(width: 60, height: 60) - .foregroundColor(.green) - - VStack(spacing: 8) { - Text("You're All Set!") - .font(.title) - .fontWeight(.bold) - - Text("Voxtype is ready to use") - .font(.body) - .foregroundColor(.secondary) - } - - // Usage instructions - VStack(alignment: .leading, spacing: 12) { - InstructionRow( - step: "1", - title: "Hold your hotkey", - description: "Hold the Right Option key to start recording" - ) - - InstructionRow( - step: "2", - title: "Speak clearly", - description: "You'll see an orange mic indicator in your menu bar" - ) - - InstructionRow( - step: "3", - title: "Release to transcribe", - description: "Let go and your speech will be typed at your cursor" - ) - } - .padding(.horizontal, 50) - .padding(.vertical, 12) - - // Hotkey reminder - HStack { - Image(systemName: "keyboard") - .foregroundColor(.accentColor) - Text("Default hotkey: ") - .foregroundColor(.secondary) - Text("Right Option (⌥)") - .fontWeight(.medium) - } - .padding(10) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) - - Spacer(minLength: 10) - - // Actions - HStack(spacing: 20) { - Button("Open Preferences") { - setupState.markWizardComplete() - } - .buttonStyle(WizardButtonStyle()) - - Button("Start Using Voxtype") { - // Save completion state and quit immediately - UserDefaults.standard.set(true, forKey: "wizardCompleted") - NSApplication.shared.terminate(nil) - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - } - .padding(.horizontal, 40) - .padding(.bottom, 20) - } - } -} - -struct InstructionRow: View { - let step: String - let title: String - let description: String - - var body: some View { - HStack(alignment: .top, spacing: 12) { - Text(step) - .font(.body) - .fontWeight(.bold) - .foregroundColor(.white) - .frame(width: 24, height: 24) - .background(Color.accentColor) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(.callout) - .fontWeight(.medium) - Text(description) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - } - } -} - -#Preview { - CompleteView() - .environmentObject(SetupState()) - .frame(width: 600, height: 500) -} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift deleted file mode 100644 index f6291f64..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/LaunchAgentView.swift +++ /dev/null @@ -1,144 +0,0 @@ -import SwiftUI - -struct LaunchAgentView: View { - @EnvironmentObject var setupState: SetupState - @StateObject private var cli = VoxtypeCLI.shared - - @State private var enableAutoStart = true - @State private var isInstalling = false - @State private var installComplete = false - @State private var errorMessage: String? - - var body: some View { - VStack(spacing: 20) { - Spacer() - - Image(systemName: "arrow.clockwise.circle.fill") - .resizable() - .frame(width: 60, height: 60) - .foregroundColor(.accentColor) - - Text("Auto-Start") - .font(.title) - .fontWeight(.bold) - - Text("Would you like Voxtype to start automatically when you log in?") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.horizontal, 60) - - VStack(spacing: 16) { - Toggle(isOn: $enableAutoStart) { - VStack(alignment: .leading, spacing: 4) { - Text("Start Voxtype at Login") - .fontWeight(.medium) - Text("Voxtype will run in the background and be ready whenever you need it") - .font(.callout) - .foregroundColor(.secondary) - } - } - .toggleStyle(.switch) - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(10) - } - .padding(.horizontal, 40) - - if let error = errorMessage { - Text(error) - .foregroundColor(.red) - .font(.callout) - } - - if installComplete { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text(enableAutoStart ? "Auto-start enabled!" : "Skipped auto-start") - .foregroundColor(.green) - } - } - - // Info box - HStack(alignment: .top, spacing: 12) { - Image(systemName: "info.circle") - .foregroundColor(.blue) - VStack(alignment: .leading, spacing: 4) { - Text("How it works") - .fontWeight(.medium) - Text("Voxtype runs as a background service. It listens for your hotkey and transcribes speech when triggered. You can always start/stop it manually from Terminal.") - .font(.callout) - .foregroundColor(.secondary) - } - } - .padding() - .background(Color.blue.opacity(0.1)) - .cornerRadius(10) - .padding(.horizontal, 40) - - Spacer() - - // Navigation - HStack { - Button("Back") { - withAnimation { - setupState.currentStep = .model - } - } - .buttonStyle(WizardButtonStyle()) - .disabled(isInstalling) - - Spacer() - - Button("Continue") { - installAndContinue() - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - .disabled(isInstalling) - } - .padding(.horizontal, 40) - .padding(.bottom, 30) - } - } - - func installAndContinue() { - isInstalling = true - errorMessage = nil - - DispatchQueue.global(qos: .userInitiated).async { - var success = true - - if enableAutoStart { - success = cli.installLaunchAgent() - } - - DispatchQueue.main.async { - isInstalling = false - - if success { - installComplete = true - - // Start the daemon - if enableAutoStart { - cli.startDaemon() - } - - // Move to complete after a brief delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - withAnimation { - setupState.currentStep = .complete - } - } - } else { - errorMessage = "Failed to install auto-start. You can set this up later." - } - } - } - } -} - -#Preview { - LaunchAgentView() - .environmentObject(SetupState()) - .frame(width: 600, height: 500) -} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift deleted file mode 100644 index 03cdc36a..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/ModelSelectionView.swift +++ /dev/null @@ -1,350 +0,0 @@ -import SwiftUI - -struct ModelSelectionView: View { - @EnvironmentObject var setupState: SetupState - @StateObject private var downloadMonitor = DownloadMonitor() - - @State private var selectedEngine: TranscriptionEngine = .parakeet - @State private var selectedModel: String = "parakeet-tdt-0.6b-v3-int8" - @State private var isDownloading = false - @State private var downloadComplete = false - @State private var errorMessage: String? - - // Static model lists to avoid repeated allocations - private let parakeetModels: [ModelInfo] = [ - ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, - description: "Fast, optimized for Apple Silicon", size: "670 MB"), - ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, - description: "Full precision", size: "1.2 GB"), - ] - - private let whisperModels: [ModelInfo] = [ - ModelInfo(name: "large-v3-turbo", engine: .whisper, - description: "Best accuracy, multilingual", size: "1.6 GB"), - ModelInfo(name: "medium.en", engine: .whisper, - description: "Good accuracy, English only", size: "1.5 GB"), - ModelInfo(name: "small.en", engine: .whisper, - description: "Balanced speed/accuracy", size: "500 MB"), - ModelInfo(name: "base.en", engine: .whisper, - description: "Fast, English only", size: "145 MB"), - ModelInfo(name: "tiny.en", engine: .whisper, - description: "Fastest, lower accuracy", size: "75 MB"), - ] - - private var displayedModels: [ModelInfo] { - selectedEngine == .parakeet ? parakeetModels : whisperModels - } - - var body: some View { - VStack(spacing: 20) { - Spacer() - - Text("Choose Speech Model") - .font(.title) - .fontWeight(.bold) - - Text("Select the speech recognition engine and model to use.") - .foregroundColor(.secondary) - - // Engine picker - Picker("Engine", selection: $selectedEngine) { - Text("Parakeet (Recommended)").tag(TranscriptionEngine.parakeet) - Text("Whisper (Multilingual)").tag(TranscriptionEngine.whisper) - } - .pickerStyle(.segmented) - .padding(.horizontal, 60) - - // Engine description - Text(selectedEngine == .parakeet - ? "Parakeet uses NVIDIA's FastConformer model, optimized for Apple Silicon. English only, but very fast." - : "Whisper is OpenAI's speech recognition model. Supports many languages with excellent accuracy.") - .font(.callout) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 60) - - // Model list (scrollable for longer lists) - ScrollView { - VStack(spacing: 8) { - if selectedEngine == .parakeet { - ForEach(parakeetModels) { model in - ModelRow( - model: model, - isSelected: selectedModel == model.name, - action: { selectedModel = model.name } - ) - } - } else { - ForEach(whisperModels) { model in - ModelRow( - model: model, - isSelected: selectedModel == model.name, - action: { selectedModel = model.name } - ) - } - } - } - .padding(.horizontal, 40) - } - .frame(maxHeight: 220) - - // Download progress - if isDownloading { - VStack(spacing: 8) { - ProgressView(value: downloadMonitor.progress) - .progressViewStyle(.linear) - Text("Downloading \(selectedModel)... \(Int(downloadMonitor.progress * 100))%") - .font(.callout) - .foregroundColor(.secondary) - if downloadMonitor.downloadedSize > 0 { - Text("\(downloadMonitor.formattedDownloaded) / \(downloadMonitor.formattedTotal)") - .font(.caption) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, 60) - } - - if let error = errorMessage { - Text(error) - .foregroundColor(.red) - .font(.callout) - } - - if downloadComplete { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("Model downloaded successfully!") - .foregroundColor(.green) - } - } - - Spacer() - - // Navigation - HStack { - Button("Back") { - withAnimation { - setupState.currentStep = .permissions - } - } - .buttonStyle(WizardButtonStyle()) - .disabled(isDownloading) - - Spacer() - - if downloadComplete { - Button("Continue") { - withAnimation { - setupState.currentStep = .launchAgent - } - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - } else { - Button("Download & Continue") { - downloadModel() - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - .disabled(isDownloading || selectedModel.isEmpty) - } - } - .padding(.horizontal, 40) - .padding(.bottom, 30) - } - .onChange(of: selectedEngine) { _ in - // Select first model when engine changes - if selectedEngine == .parakeet { - selectedModel = parakeetModels.first?.name ?? "" - } else { - selectedModel = whisperModels.first?.name ?? "" - } - } - } - - func downloadModel() { - isDownloading = true - errorMessage = nil - - // Get expected size and start monitoring - let expectedSize = getExpectedModelSize(selectedModel) - let modelsDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Application Support/voxtype/models") - downloadMonitor.startMonitoring(directory: modelsDir, expectedSize: expectedSize, modelName: selectedModel) - - Task { - do { - let cli = VoxtypeCLI.shared - try await cli.downloadModel(selectedModel, engine: selectedEngine) - await MainActor.run { - downloadMonitor.stopMonitoring() - isDownloading = false - downloadComplete = true - _ = cli.setEngine(selectedEngine) - _ = cli.setModel(selectedModel, engine: selectedEngine) - } - } catch { - await MainActor.run { - downloadMonitor.stopMonitoring() - isDownloading = false - errorMessage = "Download failed. Please check your internet connection." - } - } - } - } - - func getExpectedModelSize(_ model: String) -> Int64 { - // Expected sizes in bytes - switch model { - case "parakeet-tdt-0.6b-v3-int8": return 670_000_000 - case "parakeet-tdt-0.6b-v3": return 2_400_000_000 - case "large-v3-turbo": return 1_600_000_000 - case "medium.en": return 1_500_000_000 - case "small.en": return 500_000_000 - case "base.en": return 145_000_000 - case "tiny.en": return 75_000_000 - default: return 1_000_000_000 - } - } -} - -/// Monitors download progress by watching file/directory size -class DownloadMonitor: ObservableObject { - @Published var progress: Double = 0.0 - @Published var downloadedSize: Int64 = 0 - @Published var expectedSize: Int64 = 0 - - private var timer: Timer? - private var modelsDirectory: URL? - private var modelName: String = "" - private var initialSize: Int64 = 0 - - var formattedDownloaded: String { - ByteCountFormatter.string(fromByteCount: downloadedSize, countStyle: .file) - } - - var formattedTotal: String { - ByteCountFormatter.string(fromByteCount: expectedSize, countStyle: .file) - } - - func startMonitoring(directory: URL, expectedSize: Int64, modelName: String) { - self.modelsDirectory = directory - self.expectedSize = expectedSize - self.modelName = modelName - self.downloadedSize = 0 - self.progress = 0.0 - self.initialSize = getModelSize() - - // Poll every 0.3 seconds - timer = Timer.scheduledTimer(withTimeInterval: 0.3, repeats: true) { [weak self] _ in - self?.updateProgress() - } - } - - func stopMonitoring() { - timer?.invalidate() - timer = nil - progress = 1.0 - } - - private func updateProgress() { - let currentSize = getModelSize() - let downloaded = currentSize - initialSize - - DispatchQueue.main.async { - self.downloadedSize = downloaded - if self.expectedSize > 0 { - self.progress = min(Double(downloaded) / Double(self.expectedSize), 0.99) - } - } - } - - private func getModelSize() -> Int64 { - guard let modelsDir = modelsDirectory else { return 0 } - let fm = FileManager.default - - // For Parakeet models (directory) - let parakeetDir = modelsDir.appendingPathComponent(modelName) - if fm.fileExists(atPath: parakeetDir.path) { - return getDirectorySize(parakeetDir) - } - - // For Whisper models (single .bin file) - // Map model name to file name - let whisperFile: String - switch modelName { - case "large-v3-turbo": whisperFile = "ggml-large-v3-turbo.bin" - case "medium.en": whisperFile = "ggml-medium.en.bin" - case "small.en": whisperFile = "ggml-small.en.bin" - case "base.en": whisperFile = "ggml-base.en.bin" - case "tiny.en": whisperFile = "ggml-tiny.en.bin" - default: whisperFile = "ggml-\(modelName).bin" - } - - let whisperPath = modelsDir.appendingPathComponent(whisperFile) - if let attrs = try? fm.attributesOfItem(atPath: whisperPath.path), - let size = attrs[.size] as? Int64 { - return size - } - - return 0 - } - - private func getDirectorySize(_ url: URL) -> Int64 { - let fm = FileManager.default - guard let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else { - return 0 - } - - var total: Int64 = 0 - for case let fileURL as URL in enumerator { - if let size = try? fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize { - total += Int64(size) - } - } - return total - } -} - -struct ModelRow: View { - let model: ModelInfo - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(model.name) - .fontWeight(.medium) - Text(model.description) - .font(.callout) - .foregroundColor(.secondary) - } - - Spacer() - - Text(model.size) - .font(.callout) - .foregroundColor(.secondary) - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundColor(isSelected ? .accentColor : .secondary) - } - .padding() - .background(isSelected ? Color.accentColor.opacity(0.1) : Color(NSColor.controlBackgroundColor)) - .cornerRadius(8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2) - ) - } - .buttonStyle(.plain) - } -} - -#Preview { - ModelSelectionView() - .environmentObject(SetupState()) - .frame(width: 600, height: 500) -} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift deleted file mode 100644 index 666e1d19..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/PermissionsView.swift +++ /dev/null @@ -1,235 +0,0 @@ -import SwiftUI - -struct PermissionsView: View { - @EnvironmentObject var setupState: SetupState - @StateObject private var permissions = PermissionChecker.shared - @State private var isCheckingPermissions = false - @State private var refreshTimer: Timer? - - var allPermissionsGranted: Bool { - permissions.hasMicrophoneAccess && - permissions.hasAccessibilityAccess && - permissions.hasInputMonitoringAccess - } - - var body: some View { - VStack(spacing: 20) { - Spacer() - - Text("Permissions Required") - .font(.title) - .fontWeight(.bold) - - Text("Voxtype needs a few permissions to work properly.\nClick each button to grant access.") - .multilineTextAlignment(.center) - .foregroundColor(.secondary) - .padding(.bottom, 10) - - VStack(spacing: 16) { - ManualPermissionRow( - title: "Microphone", - description: "Add Voxtype.app to Microphone list", - icon: "mic.fill", - isGranted: permissions.hasMicrophoneAccess, - openAction: { - permissions.openMicrophoneSettings() - }, - confirmAction: { - permissions.confirmMicrophoneAccess() - } - ) - - ManualPermissionRow( - title: "Accessibility", - description: "Add Voxtype.app to Accessibility list", - icon: "accessibility", - isGranted: permissions.hasAccessibilityAccess, - openAction: { - permissions.requestAccessibilityAccess() - }, - confirmAction: { - permissions.confirmAccessibilityAccess() - } - ) - - ManualPermissionRow( - title: "Input Monitoring", - description: "Add Voxtype.app to Input Monitoring list", - icon: "keyboard", - isGranted: permissions.hasInputMonitoringAccess, - openAction: { - permissions.openInputMonitoringSettings() - }, - confirmAction: { - permissions.confirmInputMonitoringAccess() - } - ) - } - .padding(.horizontal, 40) - - // Refresh button - Button(action: { - isCheckingPermissions = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - permissions.refresh() - isCheckingPermissions = false - } - }) { - HStack { - if isCheckingPermissions { - ProgressView() - .scaleEffect(0.8) - .frame(width: 16, height: 16) - } else { - Image(systemName: "arrow.clockwise") - } - Text("Check Permissions") - } - } - .buttonStyle(.borderless) - .padding(.top, 10) - - if allPermissionsGranted { - HStack { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - Text("All permissions granted!") - .foregroundColor(.green) - } - .padding(.top, 10) - } - - Spacer() - - // Navigation - HStack { - Button("Back") { - withAnimation { - setupState.currentStep = .welcome - } - } - .buttonStyle(WizardButtonStyle()) - - Spacer() - - Button("Continue") { - withAnimation { - setupState.currentStep = .model - } - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - .disabled(!allPermissionsGranted) - } - .padding(.horizontal, 40) - .padding(.bottom, 30) - } - .onAppear { - // Start polling for permission changes - refreshTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - permissions.refresh() - } - } - .onDisappear { - refreshTimer?.invalidate() - refreshTimer = nil - } - } -} - -struct PermissionRow: View { - let title: String - let description: String - let icon: String - let isGranted: Bool - let action: () -> Void - - var body: some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundColor(isGranted ? .green : .accentColor) - .frame(width: 30) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .fontWeight(.medium) - Text(description) - .font(.callout) - .foregroundColor(.secondary) - } - - Spacer() - - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.title2) - } else { - Button("Grant Access") { - action() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(10) - } -} - -struct ManualPermissionRow: View { - let title: String - let description: String - let icon: String - let isGranted: Bool - let openAction: () -> Void - let confirmAction: () -> Void - - var body: some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundColor(isGranted ? .green : .accentColor) - .frame(width: 30) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .fontWeight(.medium) - Text(description) - .font(.callout) - .foregroundColor(.secondary) - } - - Spacer() - - if isGranted { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.title2) - } else { - HStack(spacing: 8) { - Button("Open Settings") { - openAction() - } - .controlSize(.small) - - Button("Done") { - confirmAction() - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - } - } - .padding() - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(10) - } -} - -#Preview { - PermissionsView() - .environmentObject(SetupState()) - .frame(width: 600, height: 500) -} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift deleted file mode 100644 index 42452590..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/SetupWizardView.swift +++ /dev/null @@ -1,82 +0,0 @@ -import SwiftUI - -struct SetupWizardView: View { - @EnvironmentObject var setupState: SetupState - - var body: some View { - VStack(spacing: 0) { - // Progress indicator - ProgressBar(currentStep: setupState.currentStep) - .padding(.horizontal, 40) - .padding(.vertical, 16) - - Divider() - - // Step content - Group { - switch setupState.currentStep { - case .welcome: - WelcomeView() - case .permissions: - PermissionsView() - case .model: - ModelSelectionView() - case .launchAgent: - LaunchAgentView() - case .complete: - CompleteView() - } - } - } - .frame(width: 600, height: 550) - .background(Color(NSColor.windowBackgroundColor)) - } -} - -struct ProgressBar: View { - let currentStep: SetupStep - - var body: some View { - HStack(spacing: 0) { - ForEach(Array(SetupStep.allCases.enumerated()), id: \.element) { index, step in - if index > 0 { - Rectangle() - .fill(index <= currentStep.rawValue ? Color.accentColor : Color.secondary.opacity(0.3)) - .frame(height: 2) - } - - VStack(spacing: 4) { - Circle() - .fill(index <= currentStep.rawValue ? Color.accentColor : Color.secondary.opacity(0.3)) - .frame(width: 10, height: 10) - - Text(step.title) - .font(.caption2) - .foregroundColor(index == currentStep.rawValue ? .primary : .secondary) - } - .frame(width: 80) - } - } - } -} - -// MARK: - Navigation Button Style - -struct WizardButtonStyle: ButtonStyle { - var isPrimary: Bool = false - - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.horizontal, 20) - .padding(.vertical, 10) - .background(isPrimary ? Color.accentColor : Color.secondary.opacity(0.2)) - .foregroundColor(isPrimary ? .white : .primary) - .cornerRadius(8) - .opacity(configuration.isPressed ? 0.8 : 1.0) - } -} - -#Preview { - SetupWizardView() - .environmentObject(SetupState()) -} diff --git a/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift b/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift deleted file mode 100644 index 078d645c..00000000 --- a/macos/VoxtypeSetup/Sources/SetupWizard/WelcomeView.swift +++ /dev/null @@ -1,84 +0,0 @@ -import SwiftUI - -struct WelcomeView: View { - @EnvironmentObject var setupState: SetupState - - var body: some View { - VStack(spacing: 20) { - Spacer() - - // App icon placeholder - Image(systemName: "mic.circle.fill") - .resizable() - .frame(width: 70, height: 70) - .foregroundColor(.accentColor) - - VStack(spacing: 8) { - Text("Welcome to Voxtype") - .font(.largeTitle) - .fontWeight(.bold) - - Text("Push-to-talk voice transcription for macOS") - .font(.title3) - .foregroundColor(.secondary) - } - - VStack(alignment: .leading, spacing: 12) { - FeatureRow(icon: "hand.tap", title: "Push-to-Talk", - description: "Hold a key to record, release to transcribe") - FeatureRow(icon: "text.cursor", title: "Type Anywhere", - description: "Transcribed text appears at your cursor") - FeatureRow(icon: "bolt", title: "Fast & Private", - description: "Runs locally on your Mac, no cloud required") - } - .padding(.horizontal, 50) - .padding(.vertical, 16) - - Spacer() - - // Navigation - HStack { - Spacer() - Button("Get Started") { - withAnimation { - setupState.currentStep = .permissions - } - } - .buttonStyle(WizardButtonStyle(isPrimary: true)) - } - .padding(.horizontal, 40) - .padding(.bottom, 24) - } - } -} - -struct FeatureRow: View { - let icon: String - let title: String - let description: String - - var body: some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundColor(.accentColor) - .frame(width: 30) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .fontWeight(.medium) - Text(description) - .font(.callout) - .foregroundColor(.secondary) - } - - Spacer() - } - } -} - -#Preview { - WelcomeView() - .environmentObject(SetupState()) - .frame(width: 600, height: 500) -} diff --git a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift index cb24a467..cefb282c 100644 --- a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift +++ b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift @@ -1,286 +1,71 @@ import Foundation -/// Bridge to call the voxtype CLI binary -class VoxtypeCLI: ObservableObject { - static let shared = VoxtypeCLI() - - /// Path to the voxtype binary - private let binaryPath: String - - /// Current download progress (0.0 to 1.0) - @Published var downloadProgress: Double = 0.0 - @Published var isDownloading: Bool = false - @Published var downloadError: String? = nil - - private init() { - // Look for voxtype in standard locations - let candidates = [ - "/Applications/Voxtype.app/Contents/MacOS/voxtype", - "/usr/local/bin/voxtype", - "/opt/homebrew/bin/voxtype", - Bundle.main.bundlePath + "/Contents/MacOS/voxtype" - ] - - binaryPath = candidates.first { FileManager.default.fileExists(atPath: $0) } - ?? "/Applications/Voxtype.app/Contents/MacOS/voxtype" - } - - // MARK: - Status Checks - - /// Check if a speech model is downloaded - func hasModel() -> Bool { - let output = run(["status", "--json"]) - // If status works without error about missing model, we have one - return !output.contains("model not found") && !output.contains("No model") - } - - /// Check if Voxtype has accessibility permission - /// Since we can't directly query another app's TCC status, we check if - /// the binary exists and is executable (user confirms in UI) - func checkAccessibilityPermission() -> Bool { - // We can't check another process's accessibility status - // Return true if voxtype binary exists (user must confirm manually) - return FileManager.default.isExecutableFile(atPath: binaryPath) - } - - /// Check if Voxtype has input monitoring permission - func checkInputMonitoringPermission() -> Bool { - // Same limitation - we can't check another process's TCC status - return FileManager.default.isExecutableFile(atPath: binaryPath) - } - - /// Check if LaunchAgent is installed - func hasLaunchAgent() -> Bool { - let plistPath = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/LaunchAgents/io.voxtype.daemon.plist") - return FileManager.default.fileExists(atPath: plistPath.path) - } - - /// Get current daemon status - func getStatus() -> String { - return run(["status"]).trimmingCharacters(in: .whitespacesAndNewlines) - } - - /// Get current configuration - func getConfig() -> [String: Any]? { - let output = run(["config", "--json"]) - guard let data = output.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] - else { return nil } - return json - } - - // MARK: - Model Management - - /// Get list of available models - func availableModels() -> [ModelInfo] { - // Return hardcoded list for now - could parse from CLI later - return [ - ModelInfo(name: "parakeet-tdt-0.6b-v3-int8", engine: .parakeet, - description: "Fast, optimized for Apple Silicon", size: "670 MB"), - ModelInfo(name: "parakeet-tdt-0.6b-v3", engine: .parakeet, - description: "Full precision", size: "1.2 GB"), - ModelInfo(name: "large-v3-turbo", engine: .whisper, - description: "Best accuracy, multilingual", size: "1.6 GB"), - ModelInfo(name: "medium.en", engine: .whisper, - description: "Good accuracy, English only", size: "1.5 GB"), - ModelInfo(name: "small.en", engine: .whisper, - description: "Balanced speed/accuracy", size: "500 MB"), - ModelInfo(name: "base.en", engine: .whisper, - description: "Fast, English only", size: "145 MB"), - ModelInfo(name: "tiny.en", engine: .whisper, - description: "Fastest, lower accuracy", size: "75 MB"), - ] - } - - /// Download a model (async with progress) - func downloadModel(_ model: String, engine: TranscriptionEngine) async throws { - await MainActor.run { - isDownloading = true - downloadProgress = 0.0 - downloadError = nil - } - - defer { - Task { @MainActor in - isDownloading = false - } - } - - // Correct syntax: voxtype setup --download --model - let args = ["setup", "--download", "--model", model] - - // Run with progress monitoring - let process = Process() - process.executableURL = URL(fileURLWithPath: binaryPath) - process.arguments = args - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - try process.run() - - // Monitor output for progress updates - let handle = pipe.fileHandleForReading - for try await line in handle.bytes.lines { - if let progress = parseProgress(line) { - await MainActor.run { - self.downloadProgress = progress - } - } - } - - process.waitUntilExit() - - if process.terminationStatus != 0 { - await MainActor.run { - self.downloadError = "Download failed" - } - throw CLIError.downloadFailed +/// Helper to run voxtype CLI commands +enum VoxtypeCLI { + /// Path to voxtype binary + static var binaryPath: String { + // First try the app bundle location + let bundlePath = Bundle.main.bundlePath + let appBundlePath = bundlePath + .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/voxtype") + + if FileManager.default.fileExists(atPath: appBundlePath) { + return appBundlePath } - await MainActor.run { - downloadProgress = 1.0 + // Try /Applications + let applicationsPath = "/Applications/Voxtype.app/Contents/MacOS/voxtype" + if FileManager.default.fileExists(atPath: applicationsPath) { + return applicationsPath } - } - - private func parseProgress(_ line: String) -> Double? { - // Parse progress from CLI output like "2.5%" or "100.0%" - if let range = line.range(of: #"(\d+\.?\d*)%"#, options: .regularExpression) { - let percentStr = String(line[range].dropLast()) // Remove the % - if let percent = Double(percentStr) { - return percent / 100.0 - } - } - return nil - } - // MARK: - Configuration - - /// Set the transcription engine - func setEngine(_ engine: TranscriptionEngine) -> Bool { - let args: [String] - switch engine { - case .parakeet: - args = ["setup", "parakeet", "--enable"] - case .whisper: - args = ["setup", "parakeet", "--disable"] + // Try homebrew symlink + let homebrewPath = "/opt/homebrew/bin/voxtype" + if FileManager.default.fileExists(atPath: homebrewPath) { + return homebrewPath } - let output = run(args) - return !output.contains("error") - } - - /// Set the model - func setModel(_ model: String, engine: TranscriptionEngine) -> Bool { - // Use setup model --set for both engines - let output = run(["setup", "model", "--set", model]) - return !output.contains("error") - } - - // MARK: - LaunchAgent - /// Install the LaunchAgent for auto-start - func installLaunchAgent() -> Bool { - let output = run(["setup", "launchd"]) - // Check for success message rather than absence of "failed" - // (launchctl may show "Load failed" warning but still succeed) - return output.contains("Installation complete") + // Fallback to PATH + return "voxtype" } - /// Uninstall the LaunchAgent - func uninstallLaunchAgent() -> Bool { - let output = run(["setup", "launchd", "--uninstall"]) - return !output.contains("error") - } - - /// Start the daemon - func startDaemon() { - _ = run(["daemon"]) - } - - /// Stop the daemon - func stopDaemon() { - let _ = shell("pkill -f 'voxtype daemon'") - } - - /// Restart the daemon - func restartDaemon() { - stopDaemon() - Thread.sleep(forTimeInterval: 0.5) - startDaemon() - } - - // MARK: - Helpers - - /// Run a voxtype command and return output - private func run(_ arguments: [String]) -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: binaryPath) - process.arguments = arguments + /// Run a voxtype command + @discardableResult + static func run(_ arguments: [String], wait: Bool = true) -> (output: String, success: Bool) { + let task = Process() + task.launchPath = binaryPath + task.arguments = arguments let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe + task.standardOutput = pipe + task.standardError = pipe do { - try process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" + try task.run() + + if wait { + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, task.terminationStatus == 0) + } else { + return ("", true) + } } catch { - return "error: \(error.localizedDescription)" + return ("Error: \(error.localizedDescription)", false) } } - /// Run a shell command - private func shell(_ command: String) -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = ["-c", command] - - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - - do { - try process.run() - process.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - return String(data: data, encoding: .utf8) ?? "" - } catch { - return "" - } + /// Get daemon status + static func getStatus() -> String { + let result = run(["status"]) + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) } -} - -// MARK: - Supporting Types - -enum TranscriptionEngine: String, CaseIterable { - case parakeet = "parakeet" - case whisper = "whisper" - var displayName: String { - switch self { - case .parakeet: return "Parakeet" - case .whisper: return "Whisper" - } + /// Check if daemon is running + static func isDaemonRunning() -> Bool { + let result = run(["status"]) + let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "idle" || status == "recording" || status == "transcribing" } } - -struct ModelInfo: Identifiable { - let name: String - let engine: TranscriptionEngine - let description: String - let size: String - - // Use name as stable identifier instead of UUID - var id: String { name } -} - -enum CLIError: Error { - case downloadFailed - case configFailed -} diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift index a70aac17..96dc5d9a 100644 --- a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -2,61 +2,74 @@ import SwiftUI @main struct VoxtypeSetupApp: App { - @StateObject private var setupState = SetupState() - var body: some Scene { WindowGroup { - if setupState.setupComplete { - PreferencesView() - .environmentObject(setupState) - } else { - SetupWizardView() - .environmentObject(setupState) - } + SettingsView() } + .windowStyle(.hiddenTitleBar) + .defaultSize(width: 700, height: 500) } } -/// Tracks overall setup state -class SetupState: ObservableObject { - @Published var setupComplete: Bool = false - @Published var currentStep: SetupStep = .welcome - - private let wizardCompletedKey = "wizardCompleted" - - init() { - // Only show preferences if wizard was explicitly completed - setupComplete = UserDefaults.standard.bool(forKey: wizardCompletedKey) - } +/// Main settings view with sidebar navigation +struct SettingsView: View { + @State private var selectedSection: SettingsSection = .general - /// Mark wizard as completed (called when user finishes the wizard) - func markWizardComplete() { - UserDefaults.standard.set(true, forKey: wizardCompletedKey) - setupComplete = true - } - - /// Reset to show wizard again - func resetWizard() { - UserDefaults.standard.set(false, forKey: wizardCompletedKey) - setupComplete = false - currentStep = .welcome + var body: some View { + NavigationSplitView { + List(SettingsSection.allCases, selection: $selectedSection) { section in + Label(section.title, systemImage: section.icon) + .tag(section) + } + .listStyle(.sidebar) + .navigationSplitViewColumnWidth(min: 180, ideal: 200) + } detail: { + selectedSection.view + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding() + } + .navigationTitle("Voxtype Settings") } } -enum SetupStep: Int, CaseIterable { - case welcome +/// Settings sections +enum SettingsSection: String, CaseIterable, Identifiable { + case general + case models + case output case permissions - case model - case launchAgent - case complete + case advanced + + var id: String { rawValue } var title: String { switch self { - case .welcome: return "Welcome" + case .general: return "General" + case .models: return "Models" + case .output: return "Output" case .permissions: return "Permissions" - case .model: return "Speech Model" - case .launchAgent: return "Auto-Start" - case .complete: return "Complete" + case .advanced: return "Advanced" + } + } + + var icon: String { + switch self { + case .general: return "gearshape" + case .models: return "cpu" + case .output: return "text.cursor" + case .permissions: return "lock.shield" + case .advanced: return "wrench.and.screwdriver" + } + } + + @ViewBuilder + var view: some View { + switch self { + case .general: GeneralSettingsView() + case .models: ModelsSettingsView() + case .output: OutputSettingsView() + case .permissions: PermissionsSettingsView() + case .advanced: AdvancedSettingsView() } } } From 6080247c4fc397ed72eee706f46689fd95e2f3df Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 08:50:11 -0500 Subject: [PATCH 20/33] Fix macOS settings config corruption and improve notifications Settings app fixes: - Add ConfigManager for section-aware TOML updates (fixes config corruption) - All settings views now use ConfigManager.shared instead of broken regex - Add restart banner when engine/hotkey settings change - Add new settings sections: Hotkey, Audio, Whisper, Remote Whisper, Text Processing, Notifications Menubar app fixes: - Detect daemon via PID file when not running via launchd - Fix VoxtypeCLI binary path detection Notification improvements: - Use terminal-notifier for custom icons (bundled in app) - Remove redundant app icon when engine emoji is shown - Add engine-specific emoji to notification titles Homebrew Cask: - Bundle terminal-notifier in Voxtype.app during install - Require Parakeet support in all macOS builds Documentation: - Add MACOS_ARCHITECTURE.md with component overview - Add MACOS_TROUBLESHOOTING.md with debugging checklist Co-Authored-By: Claude Opus 4.5 --- docs/MACOS_ARCHITECTURE.md | 178 +++++++++++++++ docs/MACOS_TROUBLESHOOTING.md | 183 ++++++++++++++++ .../VoxtypeMenubar/Sources/MenuBarView.swift | 52 +++-- macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift | 16 +- .../Sources/VoxtypeStatusMonitor.swift | 31 ++- macos/VoxtypeMenubar/build-app.sh | 28 +++ .../Settings/AdvancedSettingsView.swift | 50 ++++- .../Sources/Settings/AudioSettingsView.swift | 136 ++++++++++++ .../Settings/GeneralSettingsView.swift | 116 +++++----- .../Sources/Settings/HotkeySettingsView.swift | 205 ++++++++++++++++++ .../Sources/Settings/ModelsSettingsView.swift | 146 ++++++++----- .../Settings/NotificationSettingsView.swift | 113 ++++++++++ .../Sources/Settings/OutputSettingsView.swift | 59 +---- .../Settings/RemoteWhisperSettingsView.swift | 139 ++++++++++++ .../Settings/TextProcessingSettingsView.swift | 187 ++++++++++++++++ .../Settings/WhisperSettingsView.swift | 155 +++++++++++++ .../Sources/Utilities/ConfigManager.swift | 147 +++++++++++++ .../Sources/Utilities/VoxtypeCLI.swift | 16 +- .../Sources/VoxtypeSetupApp.swift | 24 ++ macos/VoxtypeSetup/build-app.sh | 28 +++ packaging/homebrew/Casks/voxtype.rb | 8 + src/notification.rs | 35 ++- 22 files changed, 1820 insertions(+), 232 deletions(-) create mode 100644 docs/MACOS_ARCHITECTURE.md create mode 100644 docs/MACOS_TROUBLESHOOTING.md create mode 100644 macos/VoxtypeSetup/Sources/Settings/AudioSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/HotkeySettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/NotificationSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/RemoteWhisperSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/TextProcessingSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift create mode 100644 macos/VoxtypeSetup/Sources/Utilities/ConfigManager.swift diff --git a/docs/MACOS_ARCHITECTURE.md b/docs/MACOS_ARCHITECTURE.md new file mode 100644 index 00000000..da930cf1 --- /dev/null +++ b/docs/MACOS_ARCHITECTURE.md @@ -0,0 +1,178 @@ +# Voxtype macOS Architecture + +This document describes the macOS-specific architecture for Voxtype. + +## Component Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ macOS System │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ ┌───────────────┐ │ +│ │ VoxtypeMenubar │ │ VoxtypeSetup │ │ Voxtype.app │ │ +│ │ (.app) │ │ (.app) │ │ (daemon) │ │ +│ │ │ │ │ │ │ │ +│ │ - Menu bar icon │ │ - Settings GUI │ │ - CLI binary │ │ +│ │ - Status display │ │ - Config editor │ │ - Transcriber │ │ +│ │ - Quick settings │ │ - Model manager │ │ - Hotkey │ │ +│ │ - Opens Setup │ │ - Permissions │ │ - Audio │ │ +│ └────────┬─────────┘ └────────┬─────────┘ └───────┬───────┘ │ +│ │ │ │ │ +│ │ Reads config │ Writes config │ │ +│ └──────────┬──────────┴──────────┬──────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ~/Library/Application Support/voxtype/ ││ +│ │ - config.toml (configuration) ││ +│ │ - models/ (Whisper/Parakeet models) ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ /tmp/voxtype/ ││ +│ │ - state (idle/recording/transcribing) ││ +│ │ - pid (daemon process ID) ││ +│ │ - voxtype.lock (prevents multiple instances) ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Applications + +### 1. Voxtype.app (Main Binary) + +**Location:** `/Applications/Voxtype.app/Contents/MacOS/voxtype` + +The core Rust binary that provides: +- `voxtype daemon` - Background service for voice transcription +- `voxtype status` - Check daemon state +- `voxtype record start/stop/toggle` - Manual recording control +- `voxtype setup` - Installation and model management + +**Key Files:** +- `src/daemon.rs` - Main event loop +- `src/hotkey_macos.rs` - macOS hotkey detection via rdev +- `src/notification.rs` - macOS notifications via mac-notification-sys +- `src/output/cgevent.rs` - Text output via CGEvent (macOS native) + +### 2. VoxtypeMenubar.app (Menu Bar Widget) + +**Location:** `/Applications/VoxtypeMenubar.app` + +Swift/SwiftUI app that provides: +- Menu bar icon showing daemon status +- Quick access to start/stop recording +- Quick settings (Engine, Output Mode, Hotkey Mode) +- Link to open VoxtypeSetup + +**Key Files:** +- `macos/VoxtypeMenubar/Sources/VoxtypeMenubarApp.swift` - App entry point +- `macos/VoxtypeMenubar/Sources/MenuBarView.swift` - Menu dropdown UI +- `macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift` - Polls /tmp/voxtype/state +- `macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift` - Runs voxtype CLI commands + +### 3. VoxtypeSetup.app (Settings Application) + +**Location:** `/Applications/VoxtypeSetup.app` + +Swift/SwiftUI app that provides: +- Full settings GUI with sidebar navigation +- Model download and management +- Permission status checking +- Daemon control (start/stop/restart) + +**Settings Sections:** +- General - Engine selection, daemon status +- Hotkey - Key selection, mode, cancel key +- Audio - Device, max duration, feedback +- Models - Installed models, download new +- Whisper - Language, translate, GPU isolation +- Remote Whisper - Server URL, API key +- Output - Mode, type delay, auto-submit +- Text Processing - Spoken punctuation, replacements +- Notifications - Event triggers, engine icon +- Permissions - macOS permissions status +- Advanced - Config file, logs, auto-start + +**Key Files:** +- `macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift` - App entry point +- `macos/VoxtypeSetup/Sources/Settings/*.swift` - Settings views +- `macos/VoxtypeSetup/Sources/Utilities/ConfigManager.swift` - Config read/write +- `macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift` - CLI integration + +## Configuration + +**Config File:** `~/Library/Application Support/voxtype/config.toml` + +The ConfigManager (in VoxtypeSetup) handles section-aware config updates to prevent corruption. + +## macOS Permissions Required + +1. **Microphone** - For audio capture +2. **Input Monitoring** - For global hotkey detection (rdev library) +3. **Accessibility** - For typing text into applications (CGEvent) + +## LaunchAgent (Auto-Start) + +**Plist Location:** `~/Library/LaunchAgents/io.voxtype.daemon.plist` + +Managed via: +- `voxtype setup launchd` - Install service +- `voxtype setup launchd --uninstall` - Remove service + +## Build Process + +### Building Swift Apps + +```bash +# Build VoxtypeMenubar +cd macos/VoxtypeMenubar +./build-app.sh + +# Build VoxtypeSetup +cd macos/VoxtypeSetup +./build-app.sh +``` + +Build scripts: +1. Run `swift build -c release` +2. Create .app bundle structure +3. Generate AppIcon.icns from assets/icon.png +4. Create Info.plist +5. Code sign with entitlements + +### Building Rust Binary + +**All macOS binaries must include Parakeet support:** + +```bash +cargo build --release --features parakeet +cp target/release/voxtype /Applications/Voxtype.app/Contents/MacOS/ +``` + +## Known Issues / TODOs + +1. **Notification Icon** - Daemon notifications use default icon (not app icon) because daemon runs as CLI process, not from app bundle context + +2. **Audio Feedback** - Currently disabled on macOS due to "use_default" file dialog issue with rodio/cpal + +3. **Unsigned Binaries** - Apps are ad-hoc signed, require "Open Anyway" in Security settings + +4. **LaunchAgent Conflicts** - If launchd keeps restarting daemon, use `launchctl unload` before manual testing + +## File Locations Summary + +| Item | Path | +|------|------| +| Main binary | `/Applications/Voxtype.app/Contents/MacOS/voxtype` | +| Menubar app | `/Applications/VoxtypeMenubar.app` | +| Settings app | `/Applications/VoxtypeSetup.app` | +| Config file | `~/Library/Application Support/voxtype/config.toml` | +| Models | `~/Library/Application Support/voxtype/models/` | +| State file | `/tmp/voxtype/state` | +| PID file | `/tmp/voxtype/pid` | +| Lock file | `/tmp/voxtype/voxtype.lock` | +| LaunchAgent | `~/Library/LaunchAgents/io.voxtype.daemon.plist` | +| Logs | `~/Library/Logs/voxtype/` (if enabled) | diff --git a/docs/MACOS_TROUBLESHOOTING.md b/docs/MACOS_TROUBLESHOOTING.md new file mode 100644 index 00000000..5b408690 --- /dev/null +++ b/docs/MACOS_TROUBLESHOOTING.md @@ -0,0 +1,183 @@ +# Voxtype macOS Troubleshooting Checklist + +Use this checklist to debug issues and resume work after context resets. + +## Quick Status Check + +```bash +# Check if daemon is running +ps aux | grep "[v]oxtype daemon" + +# Check daemon status +/Applications/Voxtype.app/Contents/MacOS/voxtype status + +# Check state file +cat /tmp/voxtype/state + +# Check config +cat "$HOME/Library/Application Support/voxtype/config.toml" | head -50 +``` + +## Common Issues + +### 1. "Another voxtype instance is already running" + +**Cause:** Stale lock file or launchd keeps restarting daemon. + +**Fix:** +```bash +# Stop launchd service +launchctl stop io.voxtype.daemon +launchctl unload ~/Library/LaunchAgents/io.voxtype.daemon.plist + +# Kill all instances +pkill -9 voxtype + +# Clean up lock files +rm -rf /tmp/voxtype + +# Start fresh +/Applications/Voxtype.app/Contents/MacOS/voxtype daemon & +``` + +### 2. Hotkey Not Working + +**Possible causes:** +- Wrong key configured +- Input Monitoring permission not granted +- Daemon not running + +**Debug:** +```bash +# Check current hotkey +grep "^key" "$HOME/Library/Application Support/voxtype/config.toml" + +# Run daemon with verbose output +pkill voxtype +rm -rf /tmp/voxtype +/Applications/Voxtype.app/Contents/MacOS/voxtype -vv daemon +``` + +**Fix permissions:** +- System Settings → Privacy & Security → Input Monitoring +- Add `/Applications/Voxtype.app` or the Terminal app + +### 3. "use_default" Dialog Appears + +**Cause:** `mac-notification-sys` crate looking for bundle identifier. + +**Fix:** Use osascript for notifications (already fixed in current code): +```rust +// In src/notification.rs, send_macos_native should use osascript +fn send_macos_native(title: &str, body: &str) { + send_macos_osascript_sync(title, body); +} +``` + +### 4. Config Changes Not Taking Effect + +**Cause:** Daemon needs restart after config changes. + +**Fix:** +```bash +pkill voxtype +rm -rf /tmp/voxtype +/Applications/Voxtype.app/Contents/MacOS/voxtype daemon & +``` + +### 5. Settings App Config Updates Corrupting File + +**Cause:** Old ConfigManager did global regex replace instead of section-aware updates. + +**Fix:** ConfigManager now does line-by-line, section-aware updates. If config is corrupted, reset: +```bash +# Backup current config +cp "$HOME/Library/Application Support/voxtype/config.toml" ~/config.toml.bak + +# Regenerate default config +/Applications/Voxtype.app/Contents/MacOS/voxtype setup --quiet +``` + +### 6. Audio Feedback "use_default" Dialog + +**Cause:** rodio/cpal audio output stream initialization on macOS. + +**Fix:** Disable audio feedback in config: +```toml +[audio.feedback] +enabled = false +``` + +### 7. Status Shows "stopped" But Daemon Is Running + +**Cause:** Multiple daemon processes or state file mismatch. + +**Fix:** +```bash +# Clean slate +pkill -9 voxtype +rm -rf /tmp/voxtype +sleep 2 +/Applications/Voxtype.app/Contents/MacOS/voxtype daemon & +sleep 3 +/Applications/Voxtype.app/Contents/MacOS/voxtype status +``` + +## Building and Installing + +### Rebuild Rust Binary +```bash +cd /Users/pete/workspace/voxtype +cargo build --release +cp target/release/voxtype /Applications/Voxtype.app/Contents/MacOS/ +``` + +### Rebuild Swift Apps +```bash +# Menubar +cd macos/VoxtypeMenubar +./build-app.sh +cp -r .build/VoxtypeMenubar.app /Applications/ + +# Settings +cd macos/VoxtypeSetup +./build-app.sh +cp -r .build/VoxtypeSetup.app /Applications/ +``` + +### Restart Apps After Rebuild +```bash +pkill -x VoxtypeMenubar +pkill -x VoxtypeSetup +pkill -x voxtype +rm -rf /tmp/voxtype +open /Applications/VoxtypeMenubar.app +/Applications/Voxtype.app/Contents/MacOS/voxtype daemon & +``` + +## Current Known Issues (as of session) + +1. **Notification icon** - Daemon uses osascript so notifications show Script Editor icon, not Voxtype icon. Menubar app notifications show correct icon. + +2. **Audio feedback disabled** - Causes "use_default" dialog on macOS. + +3. **Hotkey restart required** - Config changes to hotkey require daemon restart. Settings app now has "Restart Now" button. + +## File Locations Quick Reference + +| Item | Path | +|------|------| +| Config | `~/Library/Application Support/voxtype/config.toml` | +| Models | `~/Library/Application Support/voxtype/models/` | +| State | `/tmp/voxtype/state` | +| Lock | `/tmp/voxtype/voxtype.lock` | +| PID | `/tmp/voxtype/pid` | + +## Verification Steps + +After fixing an issue, verify: + +1. `voxtype status` returns `idle` +2. Pressing hotkey (default: Right Option) starts recording (state becomes `recording`) +3. Releasing hotkey transcribes and types text +4. Notification appears after transcription diff --git a/macos/VoxtypeMenubar/Sources/MenuBarView.swift b/macos/VoxtypeMenubar/Sources/MenuBarView.swift index 7a734773..27537436 100644 --- a/macos/VoxtypeMenubar/Sources/MenuBarView.swift +++ b/macos/VoxtypeMenubar/Sources/MenuBarView.swift @@ -37,43 +37,41 @@ struct MenuBarView: View { Divider() - // Settings submenu - Menu("Settings") { - Menu("Engine") { - Button("Parakeet (Fast)") { - setEngine("parakeet") - } - Button("Whisper") { - setEngine("whisper") - } + // Quick settings menus (at top level) + Menu("Engine") { + Button("Parakeet (Fast)") { + setEngine("parakeet") } + Button("Whisper") { + setEngine("whisper") + } + } - Menu("Output Mode") { - Button("Type Text") { - setOutputMode("type") - } - Button("Clipboard") { - setOutputMode("clipboard") - } - Button("Clipboard + Paste") { - setOutputMode("paste") - } + Menu("Output Mode") { + Button("Type Text") { + setOutputMode("type") } + Button("Clipboard") { + setOutputMode("clipboard") + } + Button("Clipboard + Paste") { + setOutputMode("paste") + } + } - Menu("Hotkey Mode") { - Button("Push-to-Talk (hold)") { - setHotkeyMode("push_to_talk") - } - Button("Toggle (press)") { - setHotkeyMode("toggle") - } + Menu("Hotkey Mode") { + Button("Push-to-Talk (hold)") { + setHotkeyMode("push_to_talk") + } + Button("Toggle (press)") { + setHotkeyMode("toggle") } } Divider() Button(action: openSettings) { - Label("Settings...", systemImage: "gearshape") + Label("Settings", systemImage: "gearshape") } Button(action: restartDaemon) { diff --git a/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift b/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift index cefb282c..10f5b31c 100644 --- a/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift +++ b/macos/VoxtypeMenubar/Sources/VoxtypeCLI.swift @@ -4,13 +4,13 @@ import Foundation enum VoxtypeCLI { /// Path to voxtype binary static var binaryPath: String { - // First try the app bundle location + // First try the app bundle location (works for both VoxtypeMenubar.app and VoxtypeSetup.app) let bundlePath = Bundle.main.bundlePath - let appBundlePath = bundlePath - .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/voxtype") + let parentDir = (bundlePath as NSString).deletingLastPathComponent + let siblingBinaryPath = (parentDir as NSString).appendingPathComponent("Voxtype.app/Contents/MacOS/voxtype") - if FileManager.default.fileExists(atPath: appBundlePath) { - return appBundlePath + if FileManager.default.fileExists(atPath: siblingBinaryPath) { + return siblingBinaryPath } // Try /Applications @@ -25,6 +25,12 @@ enum VoxtypeCLI { return homebrewPath } + // Try ~/.local/bin + let localBinPath = NSHomeDirectory() + "/.local/bin/voxtype" + if FileManager.default.fileExists(atPath: localBinPath) { + return localBinPath + } + // Fallback to PATH return "voxtype" } diff --git a/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift b/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift index d81fd9e1..0fa22d77 100644 --- a/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift +++ b/macos/VoxtypeMenubar/Sources/VoxtypeStatusMonitor.swift @@ -87,21 +87,30 @@ class VoxtypeStatusMonitor: ObservableObject { } private func isDaemonRunning() -> Bool { - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = ["list", "io.voxtype.daemon"] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe + // First check if launchd service is running + let launchctlTask = Process() + launchctlTask.launchPath = "/bin/launchctl" + launchctlTask.arguments = ["list", "io.voxtype.daemon"] + launchctlTask.standardOutput = FileHandle.nullDevice + launchctlTask.standardError = FileHandle.nullDevice do { - try task.run() - task.waitUntilExit() - return task.terminationStatus == 0 - } catch { + try launchctlTask.run() + launchctlTask.waitUntilExit() + if launchctlTask.terminationStatus == 0 { + return true + } + } catch {} + + // Fall back to checking if daemon process is running via PID file + let pidPath = "/tmp/voxtype/pid" + guard let pidString = try? String(contentsOfFile: pidPath, encoding: .utf8), + let pid = Int32(pidString.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false } + + // Check if process with this PID exists + return kill(pid, 0) == 0 } } diff --git a/macos/VoxtypeMenubar/build-app.sh b/macos/VoxtypeMenubar/build-app.sh index 02c7bafc..d5a55f4c 100755 --- a/macos/VoxtypeMenubar/build-app.sh +++ b/macos/VoxtypeMenubar/build-app.sh @@ -4,6 +4,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$SCRIPT_DIR" # Build release @@ -22,6 +23,31 @@ mkdir -p "$MACOS" "$RESOURCES" # Copy binary cp ".build/release/$APP_NAME" "$MACOS/" +# Create icns from source icon +ICON_SOURCE="$REPO_ROOT/assets/icon.png" +if [ -f "$ICON_SOURCE" ]; then + ICONSET_DIR="$SCRIPT_DIR/.build/AppIcon.iconset" + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + + # Generate all required sizes for macOS app icons + sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" 2>/dev/null + sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" 2>/dev/null + sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" 2>/dev/null + sips -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" 2>/dev/null + sips -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" 2>/dev/null + sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" 2>/dev/null + sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" 2>/dev/null + sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" 2>/dev/null + sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" 2>/dev/null + sips -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" 2>/dev/null + + # Convert iconset to icns + iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES/AppIcon.icns" + rm -rf "$ICONSET_DIR" + echo "Created app icon from $ICON_SOURCE" +fi + # Create Info.plist cat > "$CONTENTS/Info.plist" << 'EOF' @@ -36,6 +62,8 @@ cat > "$CONTENTS/Info.plist" << 'EOF' Voxtype Menu Bar CFBundleDisplayName Voxtype Menu Bar + CFBundleIconFile + AppIcon CFBundlePackageType APPL CFBundleShortVersionString diff --git a/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift index 4480576c..1e882829 100644 --- a/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/AdvancedSettingsView.swift @@ -2,6 +2,8 @@ import SwiftUI struct AdvancedSettingsView: View { @State private var autoStartEnabled: Bool = false + @State private var daemonRunning: Bool = false + @State private var daemonStatus: String = "Unknown" var body: some View { Form { @@ -68,14 +70,34 @@ struct AdvancedSettingsView: View { } Section { - Button(action: restartDaemon) { - Label("Restart Daemon", systemImage: "arrow.clockwise") + HStack { + Circle() + .fill(daemonRunning ? Color.green : Color.red) + .frame(width: 10, height: 10) + Text("Status: \(daemonStatus)") + + Spacer() + + Button("Refresh") { + checkDaemonStatus() + } + } + + if daemonRunning { + Button(action: restartDaemon) { + Label("Restart Daemon", systemImage: "arrow.clockwise") + } + } else { + Button(action: startDaemon) { + Label("Start Daemon", systemImage: "play.fill") + } } Button(action: stopDaemon) { Label("Stop Daemon", systemImage: "stop.fill") } .foregroundColor(.red) + .disabled(!daemonRunning) Button(action: runSetupCheck) { Label("Run Setup Check", systemImage: "checkmark.circle") @@ -106,6 +128,30 @@ struct AdvancedSettingsView: View { .formStyle(.grouped) .onAppear { checkAutoStartStatus() + checkDaemonStatus() + } + } + + private func checkDaemonStatus() { + let result = VoxtypeCLI.run(["status"]) + let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + + if status == "idle" || status == "recording" || status == "transcribing" { + daemonRunning = true + daemonStatus = status.capitalized + } else if status.contains("not running") || status.isEmpty || !result.success { + daemonRunning = false + daemonStatus = "Not Running" + } else { + daemonRunning = false + daemonStatus = status.capitalized + } + } + + private func startDaemon() { + VoxtypeCLI.run(["daemon"], wait: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + checkDaemonStatus() } } diff --git a/macos/VoxtypeSetup/Sources/Settings/AudioSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/AudioSettingsView.swift new file mode 100644 index 00000000..aa628319 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/AudioSettingsView.swift @@ -0,0 +1,136 @@ +import SwiftUI +import AVFoundation + +struct AudioSettingsView: View { + @State private var audioDevice: String = "default" + @State private var maxDurationSecs: Int = 60 + @State private var feedbackEnabled: Bool = false + @State private var feedbackVolume: Double = 0.7 + @State private var availableDevices: [AudioDeviceInfo] = [] + + var body: some View { + Form { + Section { + Picker("Input Device", selection: $audioDevice) { + Text("System Default").tag("default") + ForEach(availableDevices, id: \.id) { device in + Text(device.name).tag(device.id) + } + } + .onChange(of: audioDevice) { newValue in + ConfigManager.shared.updateConfig(key: "device", value: "\"\(newValue)\"", section: "[audio]") + } + + Button("Refresh Devices") { + loadAudioDevices() + } + + Text("Select the microphone to use for recording.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Audio Input") + } + + Section { + Stepper("Maximum Recording: \(maxDurationSecs) seconds", value: $maxDurationSecs, in: 10...300, step: 10) + .onChange(of: maxDurationSecs) { newValue in + ConfigManager.shared.updateConfig(key: "max_duration_secs", value: "\(newValue)", section: "[audio]") + } + + Text("Safety limit to prevent accidentally long recordings.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Recording Duration") + } + + Section { + Toggle("Enable Audio Feedback", isOn: $feedbackEnabled) + .onChange(of: feedbackEnabled) { newValue in + ConfigManager.shared.updateConfig(key: "enabled", value: newValue ? "true" : "false", section: "[audio.feedback]") + } + + if feedbackEnabled { + HStack { + Text("Volume") + Slider(value: $feedbackVolume, in: 0...1, step: 0.1) + .onChange(of: feedbackVolume) { newValue in + ConfigManager.shared.updateConfig(key: "volume", value: String(format: "%.1f", newValue), section: "[audio.feedback]") + } + Text(String(format: "%.0f%%", feedbackVolume * 100)) + .frame(width: 50) + } + } + + Text("Play audio cues when recording starts and stops.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Audio Feedback") + } + + Section { + Button("Test Microphone") { + testMicrophone() + } + + Text("Opens System Preferences to test your microphone.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Testing") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + loadAudioDevices() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let device = config["audio.device"]?.replacingOccurrences(of: "\"", with: "") { + audioDevice = device + } + + if let duration = config["audio.max_duration_secs"], let d = Int(duration) { + maxDurationSecs = d + } + + if let feedback = config["audio.feedback.enabled"] { + feedbackEnabled = feedback == "true" + } + + if let volume = config["audio.feedback.volume"], let v = Double(volume) { + feedbackVolume = v + } + } + + private func loadAudioDevices() { + availableDevices = [] + + // Get audio input devices using AVFoundation + // Use the older API for macOS 13 compatibility + let devices = AVCaptureDevice.devices(for: .audio) + + for device in devices { + availableDevices.append(AudioDeviceInfo( + id: device.uniqueID, + name: device.localizedName + )) + } + } + + private func testMicrophone() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.sound?input")! + NSWorkspace.shared.open(url) + } +} + +struct AudioDeviceInfo: Identifiable { + let id: String + let name: String +} diff --git a/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift index 1b810e6f..dfe9a0e5 100644 --- a/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift @@ -5,16 +5,34 @@ struct GeneralSettingsView: View { @State private var hotkeyMode: String = "push_to_talk" @State private var hotkey: String = "RIGHTALT" @State private var daemonRunning: Bool = false + @State private var needsRestart: Bool = false var body: some View { Form { + if needsRestart { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Engine changed. Restart daemon to apply.") + Spacer() + Button("Restart Now") { + restartDaemon() + needsRestart = false + } + .buttonStyle(.borderedProminent) + } + } + } + Section { Picker("Transcription Engine", selection: $selectedEngine) { Text("Parakeet (Fast)").tag("parakeet") Text("Whisper").tag("whisper") } .onChange(of: selectedEngine) { newValue in - updateConfig(key: "engine", value: "\"\(newValue)\"") + ConfigManager.shared.updateConfig(key: "engine", value: "\"\(newValue)\"") + needsRestart = true } Text("Parakeet is faster and recommended for most users.") @@ -34,7 +52,8 @@ struct GeneralSettingsView: View { Text("F15").tag("F15") } .onChange(of: hotkey) { newValue in - updateConfig(key: "key", value: "\"\(newValue)\"", section: "[hotkey]") + ConfigManager.shared.updateConfig(key: "key", value: "\"\(newValue)\"", section: "[hotkey]") + needsRestart = true } Picker("Mode", selection: $hotkeyMode) { @@ -42,7 +61,8 @@ struct GeneralSettingsView: View { Text("Toggle (press to start/stop)").tag("toggle") } .onChange(of: hotkeyMode) { newValue in - updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[hotkey]") + ConfigManager.shared.updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[hotkey]") + needsRestart = true } } header: { Text("Hotkey") @@ -79,18 +99,16 @@ struct GeneralSettingsView: View { } private func loadSettings() { - let config = readConfig() - - if let engine = config["engine"] { - selectedEngine = engine.replacingOccurrences(of: "\"", with: "") + if let engine = ConfigManager.shared.getString("engine") { + selectedEngine = engine } - if let key = config["hotkey.key"] { - hotkey = key.replacingOccurrences(of: "\"", with: "") + if let key = ConfigManager.shared.getString("hotkey.key") { + hotkey = key } - if let mode = config["hotkey.mode"] { - hotkeyMode = mode.replacingOccurrences(of: "\"", with: "") + if let mode = ConfigManager.shared.getString("hotkey.mode") { + hotkeyMode = mode } } @@ -101,65 +119,37 @@ struct GeneralSettingsView: View { } private func startDaemon() { - VoxtypeCLI.run(["daemon"], wait: false) + _ = VoxtypeCLI.run(["daemon"], wait: false) DispatchQueue.main.asyncAfter(deadline: .now() + 2) { checkDaemonStatus() } } private func restartDaemon() { - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = ["kickstart", "-k", "gui/\(getuid())/io.voxtype.daemon"] - try? task.run() - - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - checkDaemonStatus() - } - } - - private func readConfig() -> [String: String] { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return [:] - } - - var result: [String: String] = [:] - var currentSection = "" - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentSection = String(trimmed.dropFirst().dropLast()) - } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { - let parts = trimmed.components(separatedBy: "=") - if parts.count >= 2 { - let key = parts[0].trimmingCharacters(in: .whitespaces) - let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) - let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" - result[fullKey] = value - } + // Kill existing daemon + let killTask = Process() + killTask.launchPath = "/usr/bin/pkill" + killTask.arguments = ["-x", "voxtype"] + killTask.standardOutput = FileHandle.nullDevice + killTask.standardError = FileHandle.nullDevice + try? killTask.run() + killTask.waitUntilExit() + + // Clean up state + let rmTask = Process() + rmTask.launchPath = "/bin/rm" + rmTask.arguments = ["-rf", "/tmp/voxtype"] + rmTask.standardOutput = FileHandle.nullDevice + rmTask.standardError = FileHandle.nullDevice + try? rmTask.run() + rmTask.waitUntilExit() + + // Start daemon fresh + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + _ = VoxtypeCLI.run(["daemon"], wait: false) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.checkDaemonStatus() } } - - return result - } - - private func updateConfig(key: String, value: String, section: String? = nil) { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return - } - - let pattern = "\(key)\\s*=\\s*\"[^\"]*\"" - let replacement = "\(key) = \(value)" - - if let regex = try? NSRegularExpression(pattern: pattern, options: []) { - let range = NSRange(content.startIndex..., in: content) - content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) - } - - try? content.write(toFile: configPath, atomically: true, encoding: .utf8) } } diff --git a/macos/VoxtypeSetup/Sources/Settings/HotkeySettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/HotkeySettingsView.swift new file mode 100644 index 00000000..41702fdb --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/HotkeySettingsView.swift @@ -0,0 +1,205 @@ +import SwiftUI + +struct HotkeySettingsView: View { + @State private var hotkeyEnabled: Bool = true + @State private var hotkey: String = "RIGHTALT" + @State private var hotkeyMode: String = "push_to_talk" + @State private var cancelKey: String = "" + @State private var modelModifier: String = "" + @State private var modifiers: [String] = [] + @State private var needsRestart: Bool = false + + private let availableKeys = [ + ("Right Option (⌥)", "RIGHTALT"), + ("Right Command (⌘)", "RIGHTMETA"), + ("Right Control (⌃)", "RIGHTCTRL"), + ("Left Option (⌥)", "LEFTALT"), + ("Left Command (⌘)", "LEFTMETA"), + ("Left Control (⌃)", "LEFTCTRL"), + ("F13", "F13"), + ("F14", "F14"), + ("F15", "F15"), + ("F16", "F16"), + ("F17", "F17"), + ("F18", "F18"), + ("F19", "F19"), + ("Scroll Lock", "SCROLLLOCK"), + ("Pause", "PAUSE"), + ] + + private let availableModifiers = [ + ("None", ""), + ("Left Shift", "LEFTSHIFT"), + ("Right Shift", "RIGHTSHIFT"), + ("Left Control", "LEFTCTRL"), + ("Right Control", "RIGHTCTRL"), + ("Left Option", "LEFTALT"), + ("Right Option", "RIGHTALT"), + ] + + var body: some View { + Form { + if needsRestart { + Section { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Restart daemon to apply hotkey changes") + Spacer() + Button("Restart Now") { + restartDaemon() + } + .buttonStyle(.borderedProminent) + } + } + } + + Section { + Toggle("Enable built-in hotkey detection", isOn: $hotkeyEnabled) + .onChange(of: hotkeyEnabled) { newValue in + updateConfig(key: "enabled", value: newValue ? "true" : "false", section: "[hotkey]") + needsRestart = true + } + + Text("Disable if using compositor keybindings (Hyprland, Sway) instead.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Hotkey Detection") + } + + Section { + Picker("Hotkey", selection: $hotkey) { + ForEach(availableKeys, id: \.1) { name, value in + Text(name).tag(value) + } + } + .onChange(of: hotkey) { newValue in + updateConfig(key: "key", value: "\"\(newValue)\"", section: "[hotkey]") + needsRestart = true + } + + Picker("Mode", selection: $hotkeyMode) { + Text("Push-to-Talk (hold to record)").tag("push_to_talk") + Text("Toggle (press to start/stop)").tag("toggle") + } + .onChange(of: hotkeyMode) { newValue in + updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[hotkey]") + needsRestart = true + } + + Text(hotkeyMode == "push_to_talk" + ? "Hold the hotkey to record, release to transcribe." + : "Press once to start recording, press again to stop.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Primary Hotkey") + } + + Section { + Picker("Cancel Key", selection: $cancelKey) { + Text("None").tag("") + Text("Escape").tag("ESC") + Text("Backspace").tag("BACKSPACE") + Text("F12").tag("F12") + } + .onChange(of: cancelKey) { newValue in + if newValue.isEmpty { + // Remove the key from config + updateConfig(key: "cancel_key", value: "# disabled", section: "[hotkey]") + } else { + updateConfig(key: "cancel_key", value: "\"\(newValue)\"", section: "[hotkey]") + } + needsRestart = true + } + + Text("Press this key to cancel the current recording or transcription.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Cancel Key") + } + + Section { + Picker("Model Modifier", selection: $modelModifier) { + ForEach(availableModifiers, id: \.1) { name, value in + Text(name).tag(value) + } + } + .onChange(of: modelModifier) { newValue in + if newValue.isEmpty { + updateConfig(key: "model_modifier", value: "# disabled", section: "[hotkey]") + } else { + updateConfig(key: "model_modifier", value: "\"\(newValue)\"", section: "[hotkey]") + } + needsRestart = true + } + + Text("Hold this modifier with the hotkey to use a secondary model (e.g., larger model for difficult audio).") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Secondary Model Modifier") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let enabled = config["hotkey.enabled"] { + hotkeyEnabled = enabled == "true" + } + + if let key = config["hotkey.key"]?.replacingOccurrences(of: "\"", with: "") { + hotkey = key + } + + if let mode = config["hotkey.mode"]?.replacingOccurrences(of: "\"", with: "") { + hotkeyMode = mode + } + + if let cancel = config["hotkey.cancel_key"]?.replacingOccurrences(of: "\"", with: "") { + cancelKey = cancel + } + + if let modifier = config["hotkey.model_modifier"]?.replacingOccurrences(of: "\"", with: "") { + modelModifier = modifier + } + } + + private func updateConfig(key: String, value: String, section: String? = nil) { + ConfigManager.shared.updateConfig(key: key, value: value, section: section) + } + + private func restartDaemon() { + // First, stop any running daemon + let killTask = Process() + killTask.launchPath = "/usr/bin/pkill" + killTask.arguments = ["-x", "voxtype"] + try? killTask.run() + killTask.waitUntilExit() + + // Wait a moment for the daemon to stop + Thread.sleep(forTimeInterval: 0.5) + + // Try launchctl first (if service is installed) + let launchTask = Process() + launchTask.launchPath = "/bin/launchctl" + launchTask.arguments = ["start", "io.voxtype.daemon"] + try? launchTask.run() + launchTask.waitUntilExit() + + // If launchctl didn't work, start daemon directly + if launchTask.terminationStatus != 0 { + VoxtypeCLI.run(["daemon"], wait: false) + } + + needsRestart = false + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift index 120fbd5c..1acfa08b 100644 --- a/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/ModelsSettingsView.swift @@ -52,7 +52,17 @@ struct ModelsSettingsView: View { } .disabled(isDownloading) - Text("~640 MB") + Text("~640 MB - Quantized, fast") + .foregroundColor(.secondary) + } + + HStack { + Button("Download parakeet-tdt-0.6b-v3") { + downloadModel("parakeet-tdt-0.6b-v3") + } + .disabled(isDownloading) + + Text("~1.2 GB - Full precision") .foregroundColor(.secondary) } } @@ -60,16 +70,20 @@ struct ModelsSettingsView: View { Divider() VStack(alignment: .leading, spacing: 12) { - Text("Whisper Models") + Text("Whisper English-Only") .font(.headline) + Text("Optimized for English, faster and more accurate") + .font(.caption) + .foregroundColor(.secondary) + HStack { Button("Download base.en") { downloadModel("base.en") } .disabled(isDownloading) - Text("~142 MB - Good balance") + Text("~142 MB") .foregroundColor(.secondary) } @@ -79,7 +93,68 @@ struct ModelsSettingsView: View { } .disabled(isDownloading) - Text("~466 MB - Better accuracy") + Text("~466 MB") + .foregroundColor(.secondary) + } + + HStack { + Button("Download medium.en") { + downloadModel("medium.en") + } + .disabled(isDownloading) + + Text("~1.5 GB") + .foregroundColor(.secondary) + } + } + + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text("Whisper Multilingual") + .font(.headline) + + Text("Supports 99 languages, can translate to English") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + Button("Download base") { + downloadModel("base") + } + .disabled(isDownloading) + + Text("~142 MB") + .foregroundColor(.secondary) + } + + HStack { + Button("Download small") { + downloadModel("small") + } + .disabled(isDownloading) + + Text("~466 MB") + .foregroundColor(.secondary) + } + + HStack { + Button("Download medium") { + downloadModel("medium") + } + .disabled(isDownloading) + + Text("~1.5 GB") + .foregroundColor(.secondary) + } + + HStack { + Button("Download large-v3") { + downloadModel("large-v3") + } + .disabled(isDownloading) + + Text("~3.1 GB - Best quality") .foregroundColor(.secondary) } @@ -89,7 +164,7 @@ struct ModelsSettingsView: View { } .disabled(isDownloading) - Text("~1.6 GB - Best quality") + Text("~1.6 GB - Fast, near large quality") .foregroundColor(.secondary) } } @@ -146,14 +221,12 @@ struct ModelsSettingsView: View { installedModels = models // Get currently selected model from config - let config = readConfig() - if let engine = config["engine"]?.replacingOccurrences(of: "\"", with: ""), - engine == "parakeet" { - if let model = config["parakeet.model"]?.replacingOccurrences(of: "\"", with: "") { + if let engine = ConfigManager.shared.getString("engine"), engine == "parakeet" { + if let model = ConfigManager.shared.getString("parakeet.model") { selectedModel = model } } else { - if let model = config["whisper.model"]?.replacingOccurrences(of: "\"", with: "") { + if let model = ConfigManager.shared.getString("whisper.model") { selectedModel = model } } @@ -163,11 +236,11 @@ struct ModelsSettingsView: View { let isParakeet = name.contains("parakeet") if isParakeet { - updateConfig(key: "engine", value: "\"parakeet\"") - updateConfig(key: "model", value: "\"\(name)\"", section: "[parakeet]") + ConfigManager.shared.updateConfig(key: "engine", value: "\"parakeet\"") + ConfigManager.shared.updateConfig(key: "model", value: "\"\(name)\"", section: "[parakeet]") } else { - updateConfig(key: "engine", value: "\"whisper\"") - updateConfig(key: "model", value: "\"\(name)\"", section: "[whisper]") + ConfigManager.shared.updateConfig(key: "engine", value: "\"whisper\"") + ConfigManager.shared.updateConfig(key: "model", value: "\"\(name)\"", section: "[whisper]") } selectedModel = name @@ -213,51 +286,6 @@ struct ModelsSettingsView: View { } return String(format: "%.0f MB", mb) } - - private func readConfig() -> [String: String] { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return [:] - } - - var result: [String: String] = [:] - var currentSection = "" - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentSection = String(trimmed.dropFirst().dropLast()) - } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { - let parts = trimmed.components(separatedBy: "=") - if parts.count >= 2 { - let key = parts[0].trimmingCharacters(in: .whitespaces) - let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) - let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" - result[fullKey] = value - } - } - } - - return result - } - - private func updateConfig(key: String, value: String, section: String? = nil) { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return - } - - let pattern = "\(key)\\s*=\\s*\"[^\"]*\"" - let replacement = "\(key) = \(value)" - - if let regex = try? NSRegularExpression(pattern: pattern, options: []) { - let range = NSRange(content.startIndex..., in: content) - content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) - } - - try? content.write(toFile: configPath, atomically: true, encoding: .utf8) - } } struct ModelInfo { diff --git a/macos/VoxtypeSetup/Sources/Settings/NotificationSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/NotificationSettingsView.swift new file mode 100644 index 00000000..3bb12e59 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/NotificationSettingsView.swift @@ -0,0 +1,113 @@ +import SwiftUI + +struct NotificationSettingsView: View { + @State private var onRecordingStart: Bool = false + @State private var onRecordingStop: Bool = false + @State private var onTranscription: Bool = true + @State private var showEngineIcon: Bool = false + + var body: some View { + Form { + Section { + Toggle("Notify when recording starts", isOn: $onRecordingStart) + .onChange(of: onRecordingStart) { newValue in + ConfigManager.shared.updateConfig(key: "on_recording_start", value: newValue ? "true" : "false", section: "[output.notification]") + } + + Toggle("Notify when recording stops", isOn: $onRecordingStop) + .onChange(of: onRecordingStop) { newValue in + ConfigManager.shared.updateConfig(key: "on_recording_stop", value: newValue ? "true" : "false", section: "[output.notification]") + } + + Toggle("Show transcribed text", isOn: $onTranscription) + .onChange(of: onTranscription) { newValue in + ConfigManager.shared.updateConfig(key: "on_transcription", value: newValue ? "true" : "false", section: "[output.notification]") + } + + Text("Choose which events trigger desktop notifications.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Notification Events") + } + + Section { + Toggle("Show engine icon in notification", isOn: $showEngineIcon) + .onChange(of: showEngineIcon) { newValue in + ConfigManager.shared.updateConfig(key: "show_engine_icon", value: newValue ? "true" : "false", section: "[output.notification]") + } + + HStack(spacing: 20) { + VStack { + Text("🦜") + .font(.largeTitle) + Text("Parakeet") + .font(.caption) + } + VStack { + Text("🗣️") + .font(.largeTitle) + Text("Whisper") + .font(.caption) + } + } + .padding(.vertical, 8) + + Text("When enabled, notifications will include an icon indicating which transcription engine was used.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Engine Icon") + } + + Section { + VStack(alignment: .leading, spacing: 8) { + Text("macOS Notification Settings") + .fontWeight(.medium) + + Text("To customize notification style, banners, and sounds:") + .font(.caption) + .foregroundColor(.secondary) + + Button("Open System Notification Settings") { + openNotificationSettings() + } + } + } header: { + Text("System Settings") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let start = config["output.notification.on_recording_start"] { + onRecordingStart = start == "true" + } + + if let stop = config["output.notification.on_recording_stop"] { + onRecordingStop = stop == "true" + } + + if let trans = config["output.notification.on_transcription"] { + onTranscription = trans == "true" + } else { + // Default is true + onTranscription = true + } + + if let icon = config["output.notification.show_engine_icon"] { + showEngineIcon = icon == "true" + } + } + + private func openNotificationSettings() { + let url = URL(string: "x-apple.systempreferences:com.apple.preference.notifications")! + NSWorkspace.shared.open(url) + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift index 5d75fc96..f5294622 100644 --- a/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/OutputSettingsView.swift @@ -76,67 +76,24 @@ struct OutputSettingsView: View { } private func loadSettings() { - let config = readConfig() - - if let mode = config["output.mode"]?.replacingOccurrences(of: "\"", with: "") { + if let mode = ConfigManager.shared.getString("output.mode") { outputMode = mode } - if let fallback = config["output.fallback_to_clipboard"] { - fallbackToClipboard = fallback == "true" - } - - if let delay = config["output.type_delay_ms"], let value = Int(delay) { - typeDelayMs = value - } - - if let submit = config["output.auto_submit"] { - autoSubmit = submit == "true" + if let fallback = ConfigManager.shared.getBool("output.fallback_to_clipboard") { + fallbackToClipboard = fallback } - } - private func readConfig() -> [String: String] { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return [:] + if let delay = ConfigManager.shared.getInt("output.type_delay_ms") { + typeDelayMs = delay } - var result: [String: String] = [:] - var currentSection = "" - - for line in content.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespaces) - - if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { - currentSection = String(trimmed.dropFirst().dropLast()) - } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { - let parts = trimmed.components(separatedBy: "=") - if parts.count >= 2 { - let key = parts[0].trimmingCharacters(in: .whitespaces) - let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) - let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" - result[fullKey] = value - } - } + if let submit = ConfigManager.shared.getBool("output.auto_submit") { + autoSubmit = submit } - - return result } private func updateConfig(key: String, value: String, section: String? = nil) { - let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" - guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { - return - } - - let pattern = "\(key)\\s*=\\s*[^\\n]*" - let replacement = "\(key) = \(value)" - - if let regex = try? NSRegularExpression(pattern: pattern, options: []) { - let range = NSRange(content.startIndex..., in: content) - content = regex.stringByReplacingMatches(in: content, options: [], range: range, withTemplate: replacement) - } - - try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + ConfigManager.shared.updateConfig(key: key, value: value, section: section) } } diff --git a/macos/VoxtypeSetup/Sources/Settings/RemoteWhisperSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/RemoteWhisperSettingsView.swift new file mode 100644 index 00000000..3298bc55 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/RemoteWhisperSettingsView.swift @@ -0,0 +1,139 @@ +import SwiftUI + +struct RemoteWhisperSettingsView: View { + @State private var endpoint: String = "" + @State private var apiKey: String = "" + @State private var remoteModel: String = "whisper-1" + @State private var timeoutSecs: Int = 30 + + var body: some View { + Form { + Section { + TextField("Server URL", text: $endpoint) + .textFieldStyle(.roundedBorder) + .onSubmit { + saveEndpoint() + } + + Text("Examples:\n• whisper.cpp server: http://192.168.1.100:8080\n• OpenAI API: https://api.openai.com") + .font(.caption) + .foregroundColor(.secondary) + + Button("Save Endpoint") { + saveEndpoint() + } + } header: { + Text("Remote Endpoint") + } + + Section { + SecureField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + .onSubmit { + saveApiKey() + } + + Text("Required for OpenAI API. Can also be set via VOXTYPE_WHISPER_API_KEY environment variable.") + .font(.caption) + .foregroundColor(.secondary) + + Button("Save API Key") { + saveApiKey() + } + } header: { + Text("Authentication") + } + + Section { + TextField("Model Name", text: $remoteModel) + .textFieldStyle(.roundedBorder) + .onSubmit { + saveRemoteModel() + } + + Text("Model name to send to the remote server. Default: \"whisper-1\" for OpenAI.") + .font(.caption) + .foregroundColor(.secondary) + + Button("Save Model") { + saveRemoteModel() + } + } header: { + Text("Remote Model") + } + + Section { + Stepper("Timeout: \(timeoutSecs) seconds", value: $timeoutSecs, in: 10...120, step: 10) + .onChange(of: timeoutSecs) { newValue in + ConfigManager.shared.updateConfig(key: "remote_timeout_secs", value: "\(newValue)", section: "[whisper]") + } + + Text("Maximum time to wait for remote server response.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Timeout") + } + + Section { + VStack(alignment: .leading, spacing: 8) { + Text("To use remote Whisper:") + .fontWeight(.medium) + + Text("1. Set Whisper mode to \"Remote\" in Whisper Settings") + Text("2. Enter your server URL above") + Text("3. Add API key if required") + Text("4. Restart the daemon") + } + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Setup Instructions") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let ep = config["whisper.remote_endpoint"]?.replacingOccurrences(of: "\"", with: "") { + endpoint = ep + } + + if let key = config["whisper.remote_api_key"]?.replacingOccurrences(of: "\"", with: "") { + apiKey = key + } + + if let model = config["whisper.remote_model"]?.replacingOccurrences(of: "\"", with: "") { + remoteModel = model + } + + if let timeout = config["whisper.remote_timeout_secs"], let t = Int(timeout) { + timeoutSecs = t + } + } + + private func saveEndpoint() { + if endpoint.isEmpty { + ConfigManager.shared.updateConfig(key: "remote_endpoint", value: "# not set", section: "[whisper]") + } else { + ConfigManager.shared.updateConfig(key: "remote_endpoint", value: "\"\(endpoint)\"", section: "[whisper]") + } + } + + private func saveApiKey() { + if apiKey.isEmpty { + ConfigManager.shared.updateConfig(key: "remote_api_key", value: "# not set", section: "[whisper]") + } else { + ConfigManager.shared.updateConfig(key: "remote_api_key", value: "\"\(apiKey)\"", section: "[whisper]") + } + } + + private func saveRemoteModel() { + ConfigManager.shared.updateConfig(key: "remote_model", value: "\"\(remoteModel)\"", section: "[whisper]") + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/TextProcessingSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/TextProcessingSettingsView.swift new file mode 100644 index 00000000..cf6afab0 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/TextProcessingSettingsView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +struct TextProcessingSettingsView: View { + @State private var spokenPunctuation: Bool = false + @State private var replacements: [(key: String, value: String)] = [] + @State private var newKey: String = "" + @State private var newValue: String = "" + + var body: some View { + Form { + Section { + Toggle("Enable Spoken Punctuation", isOn: $spokenPunctuation) + .onChange(of: spokenPunctuation) { newValue in + ConfigManager.shared.updateConfig(key: "spoken_punctuation", value: newValue ? "true" : "false", section: "[text]") + } + + VStack(alignment: .leading, spacing: 4) { + Text("Convert spoken words to punctuation marks:") + .font(.caption) + .foregroundColor(.secondary) + Text("• \"period\" → \".\"") + Text("• \"comma\" → \",\"") + Text("• \"question mark\" → \"?\"") + Text("• \"exclamation point\" → \"!\"") + Text("• \"new line\" → newline") + } + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Spoken Punctuation") + } + + Section { + if replacements.isEmpty { + Text("No word replacements configured") + .foregroundColor(.secondary) + } else { + ForEach(Array(replacements.enumerated()), id: \.offset) { index, replacement in + HStack { + Text("\"\(replacement.key)\"") + Image(systemName: "arrow.right") + .foregroundColor(.secondary) + Text("\"\(replacement.value)\"") + Spacer() + Button(role: .destructive) { + removeReplacement(at: index) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + } + } + } + + Divider() + + HStack { + TextField("From", text: $newKey) + .textFieldStyle(.roundedBorder) + Image(systemName: "arrow.right") + .foregroundColor(.secondary) + TextField("To", text: $newValue) + .textFieldStyle(.roundedBorder) + Button("Add") { + addReplacement() + } + .disabled(newKey.isEmpty || newValue.isEmpty) + } + + Text("Example: \"vox type\" → \"voxtype\"") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Word Replacements") + } footer: { + Text("Replacements are case-insensitive and applied after transcription.") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let sp = config["text.spoken_punctuation"] { + spokenPunctuation = sp == "true" + } + + // Load replacements - they're stored as a TOML table + // For now, we'll parse them from the raw config file + loadReplacements() + } + + private func loadReplacements() { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + var inReplacementsSection = false + var loaded: [(key: String, value: String)] = [] + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed == "[text.replacements]" { + inReplacementsSection = true + continue + } + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + inReplacementsSection = false + continue + } + + if inReplacementsSection && trimmed.contains("=") && !trimmed.hasPrefix("#") { + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") + let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "\"", with: "") + loaded.append((key: key, value: value)) + } + } + } + + replacements = loaded + } + + private func addReplacement() { + guard !newKey.isEmpty && !newValue.isEmpty else { return } + + replacements.append((key: newKey, value: newValue)) + saveReplacements() + + newKey = "" + newValue = "" + } + + private func removeReplacement(at index: Int) { + replacements.remove(at: index) + saveReplacements() + } + + private func saveReplacements() { + let configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + guard var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + // Remove existing [text.replacements] section + var lines = content.components(separatedBy: .newlines) + var newLines: [String] = [] + var inReplacementsSection = false + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed == "[text.replacements]" { + inReplacementsSection = true + continue + } + + if inReplacementsSection && trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + inReplacementsSection = false + } + + if !inReplacementsSection { + newLines.append(line) + } + } + + // Add new [text.replacements] section + if !replacements.isEmpty { + newLines.append("") + newLines.append("[text.replacements]") + for r in replacements { + newLines.append("\"\(r.key)\" = \"\(r.value)\"") + } + } + + content = newLines.joined(separator: "\n") + try? content.write(toFile: configPath, atomically: true, encoding: .utf8) + } +} diff --git a/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift new file mode 100644 index 00000000..b41e20e3 --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift @@ -0,0 +1,155 @@ +import SwiftUI + +struct WhisperSettingsView: View { + @State private var mode: String = "local" + @State private var language: String = "en" + @State private var translate: Bool = false + @State private var gpuIsolation: Bool = false + @State private var onDemandLoading: Bool = false + @State private var initialPrompt: String = "" + + private let languages = [ + ("English", "en"), + ("Auto-detect", "auto"), + ("Spanish", "es"), + ("French", "fr"), + ("German", "de"), + ("Italian", "it"), + ("Portuguese", "pt"), + ("Dutch", "nl"), + ("Polish", "pl"), + ("Russian", "ru"), + ("Japanese", "ja"), + ("Chinese", "zh"), + ("Korean", "ko"), + ] + + var body: some View { + Form { + Section { + Picker("Mode", selection: $mode) { + Text("Local (whisper.cpp)").tag("local") + Text("Remote Server").tag("remote") + } + .onChange(of: mode) { newValue in + ConfigManager.shared.updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[whisper]") + } + + Text(mode == "local" + ? "Run transcription locally using whisper.cpp." + : "Send audio to a remote Whisper server or OpenAI API.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Whisper Mode") + } + + Section { + Picker("Language", selection: $language) { + ForEach(languages, id: \.1) { name, code in + Text(name).tag(code) + } + } + .onChange(of: language) { newValue in + ConfigManager.shared.updateConfig(key: "language", value: "\"\(newValue)\"", section: "[whisper]") + } + + Toggle("Translate to English", isOn: $translate) + .onChange(of: translate) { newValue in + ConfigManager.shared.updateConfig(key: "translate", value: newValue ? "true" : "false", section: "[whisper]") + } + + Text("When enabled, non-English speech is automatically translated to English.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Language") + } + + Section { + TextField("Initial Prompt", text: $initialPrompt, axis: .vertical) + .lineLimit(3...6) + .onSubmit { + saveInitialPrompt() + } + + Text("Hint at terminology, proper nouns, or formatting. Example: \"Technical discussion about Rust and Kubernetes.\"") + .font(.caption) + .foregroundColor(.secondary) + + Button("Save Prompt") { + saveInitialPrompt() + } + } header: { + Text("Initial Prompt") + } + + Section { + Toggle("GPU Isolation", isOn: $gpuIsolation) + .onChange(of: gpuIsolation) { newValue in + ConfigManager.shared.updateConfig(key: "gpu_isolation", value: newValue ? "true" : "false", section: "[whisper]") + } + + Text("Run transcription in a subprocess that exits after each use, releasing GPU memory. Useful for laptops with hybrid graphics.") + .font(.caption) + .foregroundColor(.secondary) + + Toggle("On-Demand Model Loading", isOn: $onDemandLoading) + .onChange(of: onDemandLoading) { newValue in + ConfigManager.shared.updateConfig(key: "on_demand_loading", value: newValue ? "true" : "false", section: "[whisper]") + } + + Text("Load model only when recording starts. Saves memory but adds latency on first recording.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Performance") + } + } + .formStyle(.grouped) + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + let config = ConfigManager.shared.readConfig() + + if let m = config["whisper.mode"]?.replacingOccurrences(of: "\"", with: "") { + mode = m + } else if let b = config["whisper.backend"]?.replacingOccurrences(of: "\"", with: "") { + // Legacy field name + mode = b + } + + if let lang = config["whisper.language"]?.replacingOccurrences(of: "\"", with: "") { + language = lang + } + + if let trans = config["whisper.translate"] { + translate = trans == "true" + } + + if let gpu = config["whisper.gpu_isolation"] { + gpuIsolation = gpu == "true" + } + + if let onDemand = config["whisper.on_demand_loading"] { + onDemandLoading = onDemand == "true" + } + + if let prompt = config["whisper.initial_prompt"]?.replacingOccurrences(of: "\"", with: "") { + initialPrompt = prompt + } + } + + private func saveInitialPrompt() { + if initialPrompt.isEmpty { + ConfigManager.shared.updateConfig(key: "initial_prompt", value: "# empty", section: "[whisper]") + } else { + // Escape quotes in the prompt + let escaped = initialPrompt.replacingOccurrences(of: "\"", with: "\\\"") + ConfigManager.shared.updateConfig(key: "initial_prompt", value: "\"\(escaped)\"", section: "[whisper]") + } + } +} diff --git a/macos/VoxtypeSetup/Sources/Utilities/ConfigManager.swift b/macos/VoxtypeSetup/Sources/Utilities/ConfigManager.swift new file mode 100644 index 00000000..e88e68ca --- /dev/null +++ b/macos/VoxtypeSetup/Sources/Utilities/ConfigManager.swift @@ -0,0 +1,147 @@ +import Foundation + +/// Centralized config file management +class ConfigManager { + static let shared = ConfigManager() + + private let configPath: String + + private init() { + configPath = NSHomeDirectory() + "/Library/Application Support/voxtype/config.toml" + } + + /// Read config file and return key-value pairs + /// Keys are in the format "section.key" (e.g., "hotkey.key", "whisper.model") + func readConfig() -> [String: String] { + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return [:] + } + + var result: [String: String] = [:] + var currentSection = "" + + for line in content.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + currentSection = String(trimmed.dropFirst().dropLast()) + } else if trimmed.contains("=") && !trimmed.hasPrefix("#") { + let parts = trimmed.components(separatedBy: "=") + if parts.count >= 2 { + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts.dropFirst().joined(separator: "=").trimmingCharacters(in: .whitespaces) + let fullKey = currentSection.isEmpty ? key : "\(currentSection).\(key)" + result[fullKey] = value + } + } + } + + return result + } + + /// Update a config value within a specific section + /// - Parameters: + /// - key: The key name (without section prefix) + /// - value: The new value (including quotes if string) + /// - section: Optional section like "[hotkey]" - if provided, only updates the key within that section + func updateConfig(key: String, value: String, section: String? = nil) { + guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { + return + } + + var lines = content.components(separatedBy: .newlines) + let targetSection = section?.trimmingCharacters(in: CharacterSet(charactersIn: "[]")) ?? "" + var currentSection = "" + var foundAndReplaced = false + + for i in 0.. String { + var lines = content.components(separatedBy: .newlines) + var sectionIndex: Int? = nil + + // Find the section + for (index, line) in lines.enumerated() { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed == section { + sectionIndex = index + break + } + } + + if let sectionIndex = sectionIndex { + // Find the end of this section (next section or end of file) + var insertIndex = sectionIndex + 1 + for i in (sectionIndex + 1).. String? { + readConfig()[key]?.replacingOccurrences(of: "\"", with: "") + } + + /// Get a boolean value from config + func getBool(_ key: String) -> Bool? { + guard let value = readConfig()[key] else { return nil } + return value == "true" + } + + /// Get an integer value from config + func getInt(_ key: String) -> Int? { + guard let value = readConfig()[key] else { return nil } + return Int(value) + } +} diff --git a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift index cefb282c..10f5b31c 100644 --- a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift +++ b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift @@ -4,13 +4,13 @@ import Foundation enum VoxtypeCLI { /// Path to voxtype binary static var binaryPath: String { - // First try the app bundle location + // First try the app bundle location (works for both VoxtypeMenubar.app and VoxtypeSetup.app) let bundlePath = Bundle.main.bundlePath - let appBundlePath = bundlePath - .replacingOccurrences(of: "VoxtypeMenubar.app", with: "Voxtype.app/Contents/MacOS/voxtype") + let parentDir = (bundlePath as NSString).deletingLastPathComponent + let siblingBinaryPath = (parentDir as NSString).appendingPathComponent("Voxtype.app/Contents/MacOS/voxtype") - if FileManager.default.fileExists(atPath: appBundlePath) { - return appBundlePath + if FileManager.default.fileExists(atPath: siblingBinaryPath) { + return siblingBinaryPath } // Try /Applications @@ -25,6 +25,12 @@ enum VoxtypeCLI { return homebrewPath } + // Try ~/.local/bin + let localBinPath = NSHomeDirectory() + "/.local/bin/voxtype" + if FileManager.default.fileExists(atPath: localBinPath) { + return localBinPath + } + // Fallback to PATH return "voxtype" } diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift index 96dc5d9a..9c2ea276 100644 --- a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -35,8 +35,14 @@ struct SettingsView: View { /// Settings sections enum SettingsSection: String, CaseIterable, Identifiable { case general + case hotkey + case audio case models + case whisper + case remoteWhisper case output + case textProcessing + case notifications case permissions case advanced @@ -45,8 +51,14 @@ enum SettingsSection: String, CaseIterable, Identifiable { var title: String { switch self { case .general: return "General" + case .hotkey: return "Hotkey" + case .audio: return "Audio" case .models: return "Models" + case .whisper: return "Whisper" + case .remoteWhisper: return "Remote Whisper" case .output: return "Output" + case .textProcessing: return "Text Processing" + case .notifications: return "Notifications" case .permissions: return "Permissions" case .advanced: return "Advanced" } @@ -55,8 +67,14 @@ enum SettingsSection: String, CaseIterable, Identifiable { var icon: String { switch self { case .general: return "gearshape" + case .hotkey: return "keyboard" + case .audio: return "mic" case .models: return "cpu" + case .whisper: return "waveform" + case .remoteWhisper: return "network" case .output: return "text.cursor" + case .textProcessing: return "text.quote" + case .notifications: return "bell" case .permissions: return "lock.shield" case .advanced: return "wrench.and.screwdriver" } @@ -66,8 +84,14 @@ enum SettingsSection: String, CaseIterable, Identifiable { var view: some View { switch self { case .general: GeneralSettingsView() + case .hotkey: HotkeySettingsView() + case .audio: AudioSettingsView() case .models: ModelsSettingsView() + case .whisper: WhisperSettingsView() + case .remoteWhisper: RemoteWhisperSettingsView() case .output: OutputSettingsView() + case .textProcessing: TextProcessingSettingsView() + case .notifications: NotificationSettingsView() case .permissions: PermissionsSettingsView() case .advanced: AdvancedSettingsView() } diff --git a/macos/VoxtypeSetup/build-app.sh b/macos/VoxtypeSetup/build-app.sh index bcabdefb..288a9c82 100755 --- a/macos/VoxtypeSetup/build-app.sh +++ b/macos/VoxtypeSetup/build-app.sh @@ -4,6 +4,7 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" cd "$SCRIPT_DIR" # Build release @@ -22,6 +23,31 @@ mkdir -p "$MACOS" "$RESOURCES" # Copy binary cp ".build/release/$APP_NAME" "$MACOS/" +# Create icns from source icon +ICON_SOURCE="$REPO_ROOT/assets/icon.png" +if [ -f "$ICON_SOURCE" ]; then + ICONSET_DIR="$SCRIPT_DIR/.build/AppIcon.iconset" + rm -rf "$ICONSET_DIR" + mkdir -p "$ICONSET_DIR" + + # Generate all required sizes for macOS app icons + sips -z 16 16 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16.png" 2>/dev/null + sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_16x16@2x.png" 2>/dev/null + sips -z 32 32 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32.png" 2>/dev/null + sips -z 64 64 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_32x32@2x.png" 2>/dev/null + sips -z 128 128 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128.png" 2>/dev/null + sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_128x128@2x.png" 2>/dev/null + sips -z 256 256 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256.png" 2>/dev/null + sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_256x256@2x.png" 2>/dev/null + sips -z 512 512 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512.png" 2>/dev/null + sips -z 1024 1024 "$ICON_SOURCE" --out "$ICONSET_DIR/icon_512x512@2x.png" 2>/dev/null + + # Convert iconset to icns + iconutil -c icns "$ICONSET_DIR" -o "$RESOURCES/AppIcon.icns" + rm -rf "$ICONSET_DIR" + echo "Created app icon from $ICON_SOURCE" +fi + # Create Info.plist cat > "$CONTENTS/Info.plist" << 'EOF' @@ -36,6 +62,8 @@ cat > "$CONTENTS/Info.plist" << 'EOF' Voxtype Setup CFBundleDisplayName Voxtype Setup + CFBundleIconFile + AppIcon CFBundlePackageType APPL CFBundleShortVersionString diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index ea2974ae..b8112914 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -13,6 +13,7 @@ end depends_on macos: ">= :ventura" + depends_on formula: "terminal-notifier" app "Voxtype.app" @@ -29,6 +30,13 @@ # Create logs directory system_command "/bin/mkdir", args: ["-p", "#{ENV["HOME"]}/Library/Logs/voxtype"] + # Bundle terminal-notifier for notifications with custom icon + system_command "/bin/cp", args: [ + "-R", + "#{HOMEBREW_PREFIX}/opt/terminal-notifier/terminal-notifier.app", + "/Applications/Voxtype.app/Contents/Resources/" + ] + # Create symlink for CLI access system_command "/bin/ln", args: ["-sf", "/Applications/Voxtype.app/Contents/MacOS/voxtype", "#{HOMEBREW_PREFIX}/bin/voxtype"] diff --git a/src/notification.rs b/src/notification.rs index 84b03df8..4bca153f 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -48,18 +48,35 @@ async fn send_linux(title: &str, body: &str) { } } -/// Send a native macOS notification using UserNotifications framework -/// This makes notifications appear under "Voxtype" in System Settings > Notifications +/// Send a macOS notification using terminal-notifier +/// Falls back to osascript if terminal-notifier is not installed #[cfg(target_os = "macos")] fn send_macos_native(title: &str, body: &str) { - use mac_notification_sys::send_notification; - - // send_notification(title, subtitle, message, options) - if let Err(e) = send_notification(title, None, body, None) { - tracing::debug!("Failed to send native notification: {:?}", e); - // Fallback to osascript if native fails - send_macos_osascript_sync(title, body); + // Try bundled terminal-notifier first, then system PATH, then osascript + let bundled_path = + "/Applications/Voxtype.app/Contents/Resources/terminal-notifier.app/Contents/MacOS/terminal-notifier"; + + let notifier_paths = [bundled_path, "terminal-notifier"]; + + for notifier in notifier_paths { + let result = std::process::Command::new(notifier) + .args(["-title", title, "-message", body, "-sender", "io.voxtype.menubar"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + match result { + Ok(status) if status.success() => { + tracing::debug!("Sent notification via {}", notifier); + return; + } + _ => continue, + } } + + // Fallback to osascript + tracing::debug!("terminal-notifier not available, using osascript"); + send_macos_osascript_sync(title, body); } /// Fallback notification via osascript (if native fails) From 5b970ddb9f0543ce4883d4e39c88f8ed025e7a32 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:05:29 -0500 Subject: [PATCH 21/33] Improve macOS menubar layout and settings UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Menubar: Compact single-line header with Label (icon + "Voxtype · Status") - Settings: Combined Whisper local/remote into single view with animated transitions - Settings: Use ConfigManager for section-aware config updates (fixes corruption) - Settings: Redesigned Models view with unified list and inline progress - Notifications: Engine-specific icons via terminal-notifier contentImage - Added engine icon assets (parakeet.png, whisper.png) Co-Authored-By: Claude Opus 4.5 --- assets/engines/parakeet.png | Bin 0 -> 45837 bytes assets/engines/whisper.png | Bin 0 -> 47363 bytes .../VoxtypeMenubar/Sources/MenuBarView.swift | 17 +- .../Settings/GeneralSettingsView.swift | 22 +- .../Sources/Settings/HotkeySettingsView.swift | 24 +- .../Sources/Settings/ModelsSettingsView.swift | 345 ++++++++---------- .../Settings/WhisperSettingsView.swift | 172 ++++++--- .../Sources/Utilities/VoxtypeCLI.swift | 35 ++ .../Sources/VoxtypeSetupApp.swift | 4 - src/daemon.rs | 8 +- src/notification.rs | 63 +++- 11 files changed, 375 insertions(+), 315 deletions(-) create mode 100644 assets/engines/parakeet.png create mode 100644 assets/engines/whisper.png diff --git a/assets/engines/parakeet.png b/assets/engines/parakeet.png new file mode 100644 index 0000000000000000000000000000000000000000..9ba10fe829c32fed8c32b036918c4519547b7d0d GIT binary patch literal 45837 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 literal 0 HcmV?d00001 diff --git a/assets/engines/whisper.png b/assets/engines/whisper.png new file mode 100644 index 0000000000000000000000000000000000000000..85e45b556292aa99199fec203d794ead44161801 GIT binary patch literal 47363 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%0D7(lO@uYtIIMw%t0=*d*Nfw{32O1llku z;GsynY!{1L9cy&Ob=CWxMdYJt4&RdaiJl3Dwfn3q)~!Y|pL-bOw~c$~?^hn+yt4x%yVWT;2fkR`W2LC={ zT&r#O^sXe0|J%}Ylb_tVZFlYaW$k#FHEJKA8yr^r`Ih!y$$}IfARfR37=QE5y5Ew7 zYaabPOw$f?3!}}}RiZd~?J8RMP=Qb+Y>&L_3yW = [] @State private var selectedModel: String = "" - @State private var isDownloading: Bool = false - @State private var downloadProgress: String = "" + @State private var downloadingModel: String? = nil + @State private var needsRestart: Bool = false + + private let allModels: [ModelCategory] = [ + ModelCategory(name: "Parakeet", description: "Fast, English-only, recommended", models: [ + ModelDefinition(id: "parakeet-tdt-0.6b-v3-int8", name: "Parakeet INT8", size: "~640 MB", description: "Quantized, fastest"), + ModelDefinition(id: "parakeet-tdt-0.6b-v3", name: "Parakeet Full", size: "~1.2 GB", description: "Full precision"), + ]), + ModelCategory(name: "Whisper English", description: "OpenAI Whisper, optimized for English", models: [ + ModelDefinition(id: "base.en", name: "Base English", size: "~142 MB", description: "Fast, good accuracy"), + ModelDefinition(id: "small.en", name: "Small English", size: "~466 MB", description: "Better accuracy"), + ModelDefinition(id: "medium.en", name: "Medium English", size: "~1.5 GB", description: "High accuracy"), + ]), + ModelCategory(name: "Whisper Multilingual", description: "Supports 99 languages", models: [ + ModelDefinition(id: "base", name: "Base", size: "~142 MB", description: "Fast, 99 languages"), + ModelDefinition(id: "small", name: "Small", size: "~466 MB", description: "Better accuracy"), + ModelDefinition(id: "medium", name: "Medium", size: "~1.5 GB", description: "High accuracy"), + ModelDefinition(id: "large-v3", name: "Large V3", size: "~3.1 GB", description: "Best quality"), + ModelDefinition(id: "large-v3-turbo", name: "Large V3 Turbo", size: "~1.6 GB", description: "Fast, near-large quality"), + ]), + ] var body: some View { Form { - Section { - if installedModels.isEmpty { - Text("No models installed") - .foregroundColor(.secondary) - } else { - ForEach(installedModels, id: \.name) { model in - HStack { - VStack(alignment: .leading) { - Text(model.name) - .fontWeight(model.name == selectedModel ? .semibold : .regular) - Text(model.size) - .font(.caption) - .foregroundColor(.secondary) - } - - Spacer() - - if model.name == selectedModel { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - } else { - Button("Select") { - selectModel(model.name) - } - } - } - .padding(.vertical, 4) - } - } - } header: { - Text("Installed Models") - } - - Section { - VStack(alignment: .leading, spacing: 12) { - Text("Parakeet (Recommended)") - .font(.headline) - - HStack { - Button("Download parakeet-tdt-0.6b-v3-int8") { - downloadModel("parakeet-tdt-0.6b-v3-int8") - } - .disabled(isDownloading) - - Text("~640 MB - Quantized, fast") - .foregroundColor(.secondary) - } - - HStack { - Button("Download parakeet-tdt-0.6b-v3") { - downloadModel("parakeet-tdt-0.6b-v3") - } - .disabled(isDownloading) - - Text("~1.2 GB - Full precision") - .foregroundColor(.secondary) - } - } - - Divider() - - VStack(alignment: .leading, spacing: 12) { - Text("Whisper English-Only") - .font(.headline) - - Text("Optimized for English, faster and more accurate") - .font(.caption) - .foregroundColor(.secondary) - - HStack { - Button("Download base.en") { - downloadModel("base.en") - } - .disabled(isDownloading) - - Text("~142 MB") - .foregroundColor(.secondary) - } - + if needsRestart { + Section { HStack { - Button("Download small.en") { - downloadModel("small.en") + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.orange) + Text("Model changed. Restart daemon to apply.") + Spacer() + Button("Restart Now") { + restartDaemon() + needsRestart = false } - .disabled(isDownloading) - - Text("~466 MB") - .foregroundColor(.secondary) - } - - HStack { - Button("Download medium.en") { - downloadModel("medium.en") - } - .disabled(isDownloading) - - Text("~1.5 GB") - .foregroundColor(.secondary) + .buttonStyle(.borderedProminent) } } + } - Divider() - - VStack(alignment: .leading, spacing: 12) { - Text("Whisper Multilingual") - .font(.headline) - - Text("Supports 99 languages, can translate to English") - .font(.caption) - .foregroundColor(.secondary) - - HStack { - Button("Download base") { - downloadModel("base") - } - .disabled(isDownloading) - - Text("~142 MB") - .foregroundColor(.secondary) - } - - HStack { - Button("Download small") { - downloadModel("small") - } - .disabled(isDownloading) - - Text("~466 MB") - .foregroundColor(.secondary) - } - - HStack { - Button("Download medium") { - downloadModel("medium") - } - .disabled(isDownloading) - - Text("~1.5 GB") - .foregroundColor(.secondary) - } - - HStack { - Button("Download large-v3") { - downloadModel("large-v3") - } - .disabled(isDownloading) - - Text("~3.1 GB - Best quality") - .foregroundColor(.secondary) + ForEach(allModels, id: \.name) { category in + Section { + ForEach(category.models, id: \.id) { model in + ModelRowView( + model: model, + isInstalled: installedModels.contains(model.id), + isSelected: selectedModel == model.id, + isDownloading: downloadingModel == model.id, + onSelect: { selectModel(model.id) }, + onDownload: { downloadModel(model.id) } + ) } - - HStack { - Button("Download large-v3-turbo") { - downloadModel("large-v3-turbo") - } - .disabled(isDownloading) - - Text("~1.6 GB - Fast, near large quality") - .foregroundColor(.secondary) - } - } - - if isDownloading { - HStack { - ProgressView() - .scaleEffect(0.8) - Text(downloadProgress) + } header: { + VStack(alignment: .leading, spacing: 2) { + Text(category.name) + Text(category.description) + .font(.caption) .foregroundColor(.secondary) + .fontWeight(.regular) } } - } header: { - Text("Download Models") } } .formStyle(.grouped) @@ -194,7 +79,7 @@ struct ModelsSettingsView: View { return } - var models: [ModelInfo] = [] + var installed: Set = [] for item in contents { let path = modelsDir + "/" + item @@ -203,22 +88,16 @@ struct ModelsSettingsView: View { FileManager.default.fileExists(atPath: path, isDirectory: &isDir) if isDir.boolValue && item.contains("parakeet") { - // Parakeet model directory - let size = getDirectorySize(path) - models.append(ModelInfo(name: item, size: formatSize(size), isParakeet: true)) + installed.insert(item) } else if item.hasPrefix("ggml-") && item.hasSuffix(".bin") { - // Whisper model file - if let attrs = try? FileManager.default.attributesOfItem(atPath: path), - let size = attrs[.size] as? Int64 { - let modelName = item - .replacingOccurrences(of: "ggml-", with: "") - .replacingOccurrences(of: ".bin", with: "") - models.append(ModelInfo(name: modelName, size: formatSize(size), isParakeet: false)) - } + let modelName = item + .replacingOccurrences(of: "ggml-", with: "") + .replacingOccurrences(of: ".bin", with: "") + installed.insert(modelName) } } - installedModels = models + installedModels = installed // Get currently selected model from config if let engine = ConfigManager.shared.getString("engine"), engine == "parakeet" { @@ -244,18 +123,17 @@ struct ModelsSettingsView: View { } selectedModel = name + needsRestart = true } private func downloadModel(_ name: String) { - isDownloading = true - downloadProgress = "Downloading \(name)..." + downloadingModel = name DispatchQueue.global().async { let result = VoxtypeCLI.run(["setup", "--download", "--model", name]) DispatchQueue.main.async { - isDownloading = false - downloadProgress = "" + downloadingModel = nil loadInstalledModels() if result.success { @@ -265,31 +143,102 @@ struct ModelsSettingsView: View { } } - private func getDirectorySize(_ path: String) -> Int64 { - var size: Int64 = 0 - if let enumerator = FileManager.default.enumerator(atPath: path) { - while let file = enumerator.nextObject() as? String { - let filePath = path + "/" + file - if let attrs = try? FileManager.default.attributesOfItem(atPath: filePath), - let fileSize = attrs[.size] as? Int64 { - size += fileSize + private func restartDaemon() { + VoxtypeCLI.restartDaemon() + } +} + +struct ModelRowView: View { + let model: ModelDefinition + let isInstalled: Bool + let isSelected: Bool + let isDownloading: Bool + let onSelect: () -> Void + let onDownload: () -> Void + + var body: some View { + HStack(spacing: 12) { + // Status icon + statusIcon + .frame(width: 20) + + // Model info + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(model.name) + .fontWeight(isSelected ? .semibold : .regular) + if isSelected { + Text("Active") + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.green) + .cornerRadius(4) + } + } + + if isDownloading { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + Text("Downloading...") + .font(.caption) + .foregroundColor(.secondary) + } + } else { + Text("\(model.size) - \(model.description)") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Spacer() + + // Action button + if isDownloading { + // No button while downloading + } else if isInstalled { + if !isSelected { + Button("Select") { + onSelect() + } + .buttonStyle(.bordered) + } + } else { + Button("Download") { + onDownload() } + .buttonStyle(.borderedProminent) } } - return size + .padding(.vertical, 4) } - private func formatSize(_ bytes: Int64) -> String { - let mb = Double(bytes) / 1_000_000 - if mb >= 1000 { - return String(format: "%.1f GB", mb / 1000) + @ViewBuilder + private var statusIcon: some View { + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + } else if isInstalled { + Image(systemName: "checkmark.circle") + .foregroundColor(.secondary) + } else { + Image(systemName: "arrow.down.circle") + .foregroundColor(.blue) } - return String(format: "%.0f MB", mb) } } -struct ModelInfo { +struct ModelCategory { + let name: String + let description: String + let models: [ModelDefinition] +} + +struct ModelDefinition { + let id: String let name: String let size: String - let isParakeet: Bool + let description: String } diff --git a/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift index b41e20e3..5da97f38 100644 --- a/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/WhisperSettingsView.swift @@ -1,13 +1,19 @@ import SwiftUI struct WhisperSettingsView: View { - @State private var mode: String = "local" + @State private var backend: String = "local" @State private var language: String = "en" @State private var translate: Bool = false @State private var gpuIsolation: Bool = false @State private var onDemandLoading: Bool = false @State private var initialPrompt: String = "" + // Remote settings + @State private var endpoint: String = "" + @State private var apiKey: String = "" + @State private var remoteModel: String = "whisper-1" + @State private var timeoutSecs: Int = 30 + private let languages = [ ("English", "en"), ("Auto-detect", "auto"), @@ -27,23 +33,93 @@ struct WhisperSettingsView: View { var body: some View { Form { Section { - Picker("Mode", selection: $mode) { + Picker("Backend", selection: $backend) { Text("Local (whisper.cpp)").tag("local") Text("Remote Server").tag("remote") } - .onChange(of: mode) { newValue in - ConfigManager.shared.updateConfig(key: "mode", value: "\"\(newValue)\"", section: "[whisper]") + .onChange(of: backend) { newValue in + ConfigManager.shared.updateConfig(key: "backend", value: "\"\(newValue)\"", section: "[whisper]") } - Text(mode == "local" + Text(backend == "local" ? "Run transcription locally using whisper.cpp." : "Send audio to a remote Whisper server or OpenAI API.") .font(.caption) .foregroundColor(.secondary) } header: { - Text("Whisper Mode") + Text("Whisper Backend") + } + + // Remote-only settings + if backend == "remote" { + Group { + Section { + TextField("Server URL", text: $endpoint) + .textFieldStyle(.roundedBorder) + .onSubmit { saveEndpoint() } + + Text("Examples: http://192.168.1.100:8080 or https://api.openai.com") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Remote Endpoint") + } + + Section { + SecureField("API Key", text: $apiKey) + .textFieldStyle(.roundedBorder) + .onSubmit { saveApiKey() } + + Text("Required for OpenAI API. Can also use VOXTYPE_WHISPER_API_KEY env var.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Authentication") + } + + Section { + TextField("Model Name", text: $remoteModel) + .textFieldStyle(.roundedBorder) + .onSubmit { saveRemoteModel() } + + Stepper("Timeout: \(timeoutSecs)s", value: $timeoutSecs, in: 10...120, step: 10) + .onChange(of: timeoutSecs) { newValue in + ConfigManager.shared.updateConfig(key: "remote_timeout_secs", value: "\(newValue)", section: "[whisper]") + } + } header: { + Text("Remote Options") + } + } + .transition(.opacity.combined(with: .move(edge: .top))) + } + + // Local-only settings + if backend == "local" { + Section { + Toggle("GPU Isolation", isOn: $gpuIsolation) + .onChange(of: gpuIsolation) { newValue in + ConfigManager.shared.updateConfig(key: "gpu_isolation", value: newValue ? "true" : "false", section: "[whisper]") + } + + Text("Run in subprocess that exits after use, releasing GPU memory.") + .font(.caption) + .foregroundColor(.secondary) + + Toggle("On-Demand Loading", isOn: $onDemandLoading) + .onChange(of: onDemandLoading) { newValue in + ConfigManager.shared.updateConfig(key: "on_demand_loading", value: newValue ? "true" : "false", section: "[whisper]") + } + + Text("Load model only when recording. Saves memory but adds latency.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Performance") + } + .transition(.opacity.combined(with: .move(edge: .top))) } + // Shared settings (both local and remote) Section { Picker("Language", selection: $language) { ForEach(languages, id: \.1) { name, code in @@ -59,7 +135,7 @@ struct WhisperSettingsView: View { ConfigManager.shared.updateConfig(key: "translate", value: newValue ? "true" : "false", section: "[whisper]") } - Text("When enabled, non-English speech is automatically translated to English.") + Text("Translate non-English speech to English.") .font(.caption) .foregroundColor(.secondary) } header: { @@ -68,45 +144,18 @@ struct WhisperSettingsView: View { Section { TextField("Initial Prompt", text: $initialPrompt, axis: .vertical) - .lineLimit(3...6) - .onSubmit { - saveInitialPrompt() - } + .lineLimit(2...4) + .onSubmit { saveInitialPrompt() } - Text("Hint at terminology, proper nouns, or formatting. Example: \"Technical discussion about Rust and Kubernetes.\"") + Text("Hint at terminology or formatting. Example: \"Technical discussion about Rust.\"") .font(.caption) .foregroundColor(.secondary) - - Button("Save Prompt") { - saveInitialPrompt() - } } header: { Text("Initial Prompt") } - - Section { - Toggle("GPU Isolation", isOn: $gpuIsolation) - .onChange(of: gpuIsolation) { newValue in - ConfigManager.shared.updateConfig(key: "gpu_isolation", value: newValue ? "true" : "false", section: "[whisper]") - } - - Text("Run transcription in a subprocess that exits after each use, releasing GPU memory. Useful for laptops with hybrid graphics.") - .font(.caption) - .foregroundColor(.secondary) - - Toggle("On-Demand Model Loading", isOn: $onDemandLoading) - .onChange(of: onDemandLoading) { newValue in - ConfigManager.shared.updateConfig(key: "on_demand_loading", value: newValue ? "true" : "false", section: "[whisper]") - } - - Text("Load model only when recording starts. Saves memory but adds latency on first recording.") - .font(.caption) - .foregroundColor(.secondary) - } header: { - Text("Performance") - } } .formStyle(.grouped) + .animation(.easeInOut(duration: 0.25), value: backend) .onAppear { loadSettings() } @@ -115,11 +164,8 @@ struct WhisperSettingsView: View { private func loadSettings() { let config = ConfigManager.shared.readConfig() - if let m = config["whisper.mode"]?.replacingOccurrences(of: "\"", with: "") { - mode = m - } else if let b = config["whisper.backend"]?.replacingOccurrences(of: "\"", with: "") { - // Legacy field name - mode = b + if let b = config["whisper.backend"]?.replacingOccurrences(of: "\"", with: "") { + backend = b } if let lang = config["whisper.language"]?.replacingOccurrences(of: "\"", with: "") { @@ -141,15 +187,51 @@ struct WhisperSettingsView: View { if let prompt = config["whisper.initial_prompt"]?.replacingOccurrences(of: "\"", with: "") { initialPrompt = prompt } + + // Remote settings + if let ep = config["whisper.remote_endpoint"]?.replacingOccurrences(of: "\"", with: "") { + endpoint = ep + } + + if let key = config["whisper.remote_api_key"]?.replacingOccurrences(of: "\"", with: "") { + apiKey = key + } + + if let model = config["whisper.remote_model"]?.replacingOccurrences(of: "\"", with: "") { + remoteModel = model + } + + if let timeout = config["whisper.remote_timeout_secs"], let t = Int(timeout) { + timeoutSecs = t + } } private func saveInitialPrompt() { if initialPrompt.isEmpty { - ConfigManager.shared.updateConfig(key: "initial_prompt", value: "# empty", section: "[whisper]") + ConfigManager.shared.updateConfig(key: "initial_prompt", value: "\"\"", section: "[whisper]") } else { - // Escape quotes in the prompt let escaped = initialPrompt.replacingOccurrences(of: "\"", with: "\\\"") ConfigManager.shared.updateConfig(key: "initial_prompt", value: "\"\(escaped)\"", section: "[whisper]") } } + + private func saveEndpoint() { + if endpoint.isEmpty { + ConfigManager.shared.updateConfig(key: "remote_endpoint", value: "\"\"", section: "[whisper]") + } else { + ConfigManager.shared.updateConfig(key: "remote_endpoint", value: "\"\(endpoint)\"", section: "[whisper]") + } + } + + private func saveApiKey() { + if apiKey.isEmpty { + ConfigManager.shared.updateConfig(key: "remote_api_key", value: "\"\"", section: "[whisper]") + } else { + ConfigManager.shared.updateConfig(key: "remote_api_key", value: "\"\(apiKey)\"", section: "[whisper]") + } + } + + private func saveRemoteModel() { + ConfigManager.shared.updateConfig(key: "remote_model", value: "\"\(remoteModel)\"", section: "[whisper]") + } } diff --git a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift index 10f5b31c..90ed9c47 100644 --- a/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift +++ b/macos/VoxtypeSetup/Sources/Utilities/VoxtypeCLI.swift @@ -74,4 +74,39 @@ enum VoxtypeCLI { let status = result.output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return status == "idle" || status == "recording" || status == "transcribing" } + + /// Restart the daemon (stop, clean up, start fresh) + static func restartDaemon(completion: (() -> Void)? = nil) { + DispatchQueue.global().async { + // Kill daemon with SIGKILL to ensure it stops + let killTask = Process() + killTask.launchPath = "/usr/bin/pkill" + killTask.arguments = ["-9", "voxtype"] + killTask.standardOutput = FileHandle.nullDevice + killTask.standardError = FileHandle.nullDevice + try? killTask.run() + killTask.waitUntilExit() + + // Wait for process to fully terminate + Thread.sleep(forTimeInterval: 0.5) + + // Clean up lock and state files + let rmTask = Process() + rmTask.launchPath = "/bin/rm" + rmTask.arguments = ["-rf", "/tmp/voxtype"] + rmTask.standardOutput = FileHandle.nullDevice + rmTask.standardError = FileHandle.nullDevice + try? rmTask.run() + rmTask.waitUntilExit() + + // Wait a moment for filesystem to sync + Thread.sleep(forTimeInterval: 0.5) + + // Start daemon + DispatchQueue.main.async { + _ = run(["daemon"], wait: false) + completion?() + } + } + } } diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift index 9c2ea276..41f96527 100644 --- a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -39,7 +39,6 @@ enum SettingsSection: String, CaseIterable, Identifiable { case audio case models case whisper - case remoteWhisper case output case textProcessing case notifications @@ -55,7 +54,6 @@ enum SettingsSection: String, CaseIterable, Identifiable { case .audio: return "Audio" case .models: return "Models" case .whisper: return "Whisper" - case .remoteWhisper: return "Remote Whisper" case .output: return "Output" case .textProcessing: return "Text Processing" case .notifications: return "Notifications" @@ -71,7 +69,6 @@ enum SettingsSection: String, CaseIterable, Identifiable { case .audio: return "mic" case .models: return "cpu" case .whisper: return "waveform" - case .remoteWhisper: return "network" case .output: return "text.cursor" case .textProcessing: return "text.quote" case .notifications: return "bell" @@ -88,7 +85,6 @@ enum SettingsSection: String, CaseIterable, Identifiable { case .audio: AudioSettingsView() case .models: ModelsSettingsView() case .whisper: WhisperSettingsView() - case .remoteWhisper: RemoteWhisperSettingsView() case .output: OutputSettingsView() case .textProcessing: TextProcessingSettingsView() case .notifications: NotificationSettingsView() diff --git a/src/daemon.rs b/src/daemon.rs index 8897bfa4..6496496c 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -30,13 +30,19 @@ use tokio::signal::unix::{signal, SignalKind}; /// Send a desktop notification with optional engine icon async fn send_notification(title: &str, body: &str, show_engine_icon: bool, engine: crate::config::TranscriptionEngine) { + // On Linux, add emoji to title. On macOS, use content image instead. + #[cfg(target_os = "linux")] let title = if show_engine_icon { format!("{} {}", crate::output::engine_icon(engine), title) } else { title.to_string() }; + #[cfg(not(target_os = "linux"))] + let title = title.to_string(); - notification::send(&title, body).await; + // Pass engine for macOS content image when show_engine_icon is enabled + let engine_for_icon = if show_engine_icon { Some(engine) } else { None }; + notification::send_with_engine(&title, body, engine_for_icon).await; } /// Write state to file for external integrations (e.g., Waybar) diff --git a/src/notification.rs b/src/notification.rs index 4bca153f..b9e307df 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -3,28 +3,41 @@ //! Provides a unified interface for sending desktop notifications on //! different platforms: //! - Linux: Uses notify-send (libnotify) -//! - macOS: Uses native UserNotifications framework (appears under "Voxtype" in settings) +//! - macOS: Uses terminal-notifier with engine-specific icons use std::process::Stdio; #[cfg(target_os = "linux")] use tokio::process::Command; +use crate::config::TranscriptionEngine; + /// Send a desktop notification with the given title and body. /// /// This function is async and non-blocking. Notification failures are /// logged but don't propagate errors (notifications are best-effort). pub async fn send(title: &str, body: &str) { + send_with_engine(title, body, None).await; +} + +/// Send a desktop notification with optional engine icon. +/// +/// On macOS, when an engine is provided, the engine-specific icon is shown +/// as a content image in the notification. +pub async fn send_with_engine(title: &str, body: &str, engine: Option) { #[cfg(target_os = "linux")] - send_linux(title, body).await; + { + let _ = engine; // Linux doesn't use engine icons in notifications + send_linux(title, body).await; + } #[cfg(target_os = "macos")] - send_macos_native(title, body); + send_macos_native(title, body, engine); #[cfg(not(any(target_os = "linux", target_os = "macos")))] { tracing::debug!("Notifications not supported on this platform"); - let _ = (title, body); // Suppress unused warnings + let _ = (title, body, engine); // Suppress unused warnings } } @@ -51,19 +64,35 @@ async fn send_linux(title: &str, body: &str) { /// Send a macOS notification using terminal-notifier /// Falls back to osascript if terminal-notifier is not installed #[cfg(target_os = "macos")] -fn send_macos_native(title: &str, body: &str) { +fn send_macos_native(title: &str, body: &str, engine: Option) { // Try bundled terminal-notifier first, then system PATH, then osascript let bundled_path = "/Applications/Voxtype.app/Contents/Resources/terminal-notifier.app/Contents/MacOS/terminal-notifier"; let notifier_paths = [bundled_path, "terminal-notifier"]; + // Engine-specific content images + let content_image = engine.map(|e| match e { + TranscriptionEngine::Parakeet => { + "/Applications/Voxtype.app/Contents/Resources/parakeet.png" + } + TranscriptionEngine::Whisper => { + "/Applications/Voxtype.app/Contents/Resources/whisper.png" + } + }); + for notifier in notifier_paths { - let result = std::process::Command::new(notifier) - .args(["-title", title, "-message", body, "-sender", "io.voxtype.menubar"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); + let mut cmd = std::process::Command::new(notifier); + cmd.args(["-title", title, "-message", body, "-sender", "io.voxtype.menubar"]); + + if let Some(image_path) = content_image { + // Only add content image if the file exists + if std::path::Path::new(image_path).exists() { + cmd.args(["-contentImage", image_path]); + } + } + + let result = cmd.stdout(Stdio::null()).stderr(Stdio::null()).status(); match result { Ok(status) if status.success() => { @@ -101,15 +130,23 @@ fn send_macos_osascript_sync(title: &str, body: &str) { /// /// Used in non-async contexts like early startup warnings. pub fn send_sync(title: &str, body: &str) { + send_sync_with_engine(title, body, None); +} + +/// Send a notification synchronously with optional engine icon. +pub fn send_sync_with_engine(title: &str, body: &str, engine: Option) { #[cfg(target_os = "linux")] - send_linux_sync(title, body); + { + let _ = engine; + send_linux_sync(title, body); + } #[cfg(target_os = "macos")] - send_macos_native(title, body); + send_macos_native(title, body, engine); #[cfg(not(any(target_os = "linux", target_os = "macos")))] { - let _ = (title, body); // Suppress unused warnings + let _ = (title, body, engine); // Suppress unused warnings } } From 48455193b5bb01c4065d7ff3a033341aa9e17632 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:14:04 -0500 Subject: [PATCH 22/33] Add Christopher Albert to contributors for macOS port Co-Authored-By: Claude Opus 4.5 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 01488833..7085dce5 100644 --- a/README.md +++ b/README.md @@ -576,6 +576,7 @@ We want to hear from you! Voxtype is a young project and your feedback helps mak - [Zubair](https://github.com/mzubair481) - dotool output driver with keyboard layout support - [ayoahha](https://github.com/ayoahha) - CLI backend for whisper-cli subprocess transcription - [Loki Coyote](https://github.com/lokkju) - eitype output driver for KDE/GNOME support, media keys and numeric keycode hotkey support +- [Christopher Albert](https://github.com/krystophny) - macOS port foundation, CoreAudio capture, CGEvent output, Homebrew packaging ## License From b1562e2be09bcb5b63f300ab7e3fa4d2e416874c Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:17:11 -0500 Subject: [PATCH 23/33] Add Homebrew installation to website download page - Added macOS/Homebrew card to package manager section - Updated macOS system requirements Co-Authored-By: Claude Opus 4.5 --- website/download/index.html | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/website/download/index.html b/website/download/index.html index 89dd6ad1..a6be9b2c 100644 --- a/website/download/index.html +++ b/website/download/index.html @@ -520,6 +520,22 @@

# In configuration.nix
environment.systemPackages = [ pkgs.voxtype ];

# Or try it
nix-shell -p voxtype
+ + +
+

+ + + + + + macOS +

+

Install via Homebrew (beta)

+
+ # Add the tap
brew tap peteonrails/voxtype

# Install
brew install --cask voxtype
+
+
@@ -543,8 +559,9 @@

Linux

macOS

    -
  • macOS 12 Monterey or later
  • -
  • Apple Silicon (M1/M2/M3) or Intel
  • +
  • macOS 13 Ventura or later
  • +
  • Apple Silicon (M1/M2/M3/M4) or Intel
  • +
  • Homebrew package manager
  • 4GB RAM minimum
  • 500MB disk space
From bdfc019865bf779f1ebad05a25dc69d9cfc04f4a Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:32:47 -0500 Subject: [PATCH 24/33] Improve macOS Homebrew installation experience - Update DMG build script to bundle VoxtypeMenubar and VoxtypeSetup apps - Include engine notification icons (parakeet.png, whisper.png) - Auto-run setup and download model during brew install - Launch menubar app automatically after installation - Update caveats with cleaner instructions Co-Authored-By: Claude Opus 4.5 --- packaging/homebrew/Casks/voxtype.rb | 34 ++++-- scripts/build-macos-dmg.sh | 178 +++++++++++++++++++--------- 2 files changed, 140 insertions(+), 72 deletions(-) diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index b8112914..ab647a3b 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -1,8 +1,8 @@ cask "voxtype" do version "0.6.0-rc1" - sha256 "ad5c4f2531ed50ed028ec7e85062abeb2e64c27e8d1becb84b4946b631ba7aeb" + sha256 "b98f426c74f35cc769191ad9c427078f64695017a0273bf323b259488962bb30" - url "https://github.com/peteonrails/voxtype/releases/download/v#{version}/Voxtype-#{version}-macos-arm64.dmg" + url "file:///Users/pete/workspace/voxtype/releases/0.6.0-rc1/Voxtype-0.6.0-rc1-macos-arm64.dmg" name "Voxtype" desc "Push-to-talk voice-to-text for macOS" homepage "https://voxtype.io" @@ -80,8 +80,17 @@ File.write(plist_path, plist_content) - # Load the LaunchAgent + # Run initial setup to create config and download model + # This is non-interactive and downloads the smallest fast model + system_command "/Applications/Voxtype.app/Contents/MacOS/voxtype", + args: ["setup", "--download", "--model", "parakeet-tdt-0.6b-v3-int8"], + print_stdout: true + + # Now load the LaunchAgent (after setup is complete) system_command "/bin/launchctl", args: ["load", plist_path] + + # Launch the menubar app + system_command "/usr/bin/open", args: ["/Applications/Voxtype.app/Contents/MacOS/VoxtypeMenubar.app"] end uninstall_postflight do @@ -103,21 +112,20 @@ ] caveats <<~EOS - Voxtype is installed and will start automatically at login. - - First-time setup: + Voxtype is installed and running! - 1. If prompted "Voxtype was blocked", go to System Settings > - Privacy & Security and click "Open Anyway" + The Parakeet speech model was downloaded automatically. + Look for the microphone icon in your menu bar. - 2. Download a speech model: - voxtype setup --download --model parakeet-tdt-0.6b-v3-int8 + If prompted "Voxtype was blocked", go to: + System Settings > Privacy & Security > click "Open Anyway" - 3. Grant Input Monitoring permission in System Settings > - Privacy & Security > Input Monitoring (required for hotkey) + To use hotkeys, grant Input Monitoring permission: + System Settings > Privacy & Security > Input Monitoring Default hotkey: Right Option (hold to record, release to transcribe) - For menu bar status icon: voxtype menubar + Settings: Click menu bar icon > Settings + CLI help: voxtype --help EOS end diff --git a/scripts/build-macos-dmg.sh b/scripts/build-macos-dmg.sh index b2fc3548..e1d465c3 100755 --- a/scripts/build-macos-dmg.sh +++ b/scripts/build-macos-dmg.sh @@ -2,12 +2,18 @@ # # Create a DMG installer for macOS # +# This script builds a complete Voxtype.app bundle containing: +# - voxtype CLI binary +# - VoxtypeMenubar.app (menu bar status icon) +# - VoxtypeSetup.app (settings UI) +# - Engine notification icons +# # Requires: -# - Universal binary already built and signed -# - create-dmg tool (brew install create-dmg) +# - voxtype binary already built (arm64 or universal) +# - Swift apps will be built automatically # # Usage: -# ./scripts/build-macos-dmg.sh 0.5.0 +# ./scripts/build-macos-dmg.sh 0.6.0-rc1 set -euo pipefail @@ -15,7 +21,7 @@ VERSION="${1:-}" if [[ -z "$VERSION" ]]; then echo "Usage: $0 VERSION" - echo "Example: $0 0.5.0" + echo "Example: $0 0.6.0-rc1" exit 1 fi @@ -25,76 +31,126 @@ GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' -BINARY="releases/${VERSION}/voxtype-${VERSION}-macos-universal" -DMG_PATH="releases/${VERSION}/voxtype-${VERSION}-macos-universal.dmg" - -if [[ ! -f "$BINARY" ]]; then - echo -e "${RED}Error: Binary not found: $BINARY${NC}" - echo "Run ./scripts/build-macos.sh $VERSION first" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +RELEASES_DIR="${PROJECT_DIR}/releases/${VERSION}" +APP_DIR="${RELEASES_DIR}/Voxtype.app" + +# Find the binary (try arm64 first, then universal) +if [[ -f "${RELEASES_DIR}/voxtype-${VERSION}-macos-arm64" ]]; then + BINARY="${RELEASES_DIR}/voxtype-${VERSION}-macos-arm64" + DMG_PATH="${RELEASES_DIR}/Voxtype-${VERSION}-macos-arm64.dmg" +elif [[ -f "${RELEASES_DIR}/voxtype-${VERSION}-macos-universal" ]]; then + BINARY="${RELEASES_DIR}/voxtype-${VERSION}-macos-universal" + DMG_PATH="${RELEASES_DIR}/Voxtype-${VERSION}-macos-universal.dmg" +else + echo -e "${RED}Error: No binary found in ${RELEASES_DIR}${NC}" + echo "Expected: voxtype-${VERSION}-macos-arm64 or voxtype-${VERSION}-macos-universal" exit 1 fi -echo -e "${GREEN}Creating DMG for voxtype ${VERSION}...${NC}" +echo -e "${GREEN}Building Voxtype.app for ${VERSION}...${NC}" echo "Binary: $BINARY" echo -# Check for create-dmg -if ! command -v create-dmg &> /dev/null; then - echo -e "${YELLOW}Installing create-dmg...${NC}" - brew install create-dmg +# Build Swift apps +echo -e "${YELLOW}Building VoxtypeMenubar...${NC}" +cd "${PROJECT_DIR}/macos/VoxtypeMenubar" +./build-app.sh > /dev/null 2>&1 +MENUBAR_APP="${PROJECT_DIR}/macos/VoxtypeMenubar/.build/VoxtypeMenubar.app" + +echo -e "${YELLOW}Building VoxtypeSetup...${NC}" +cd "${PROJECT_DIR}/macos/VoxtypeSetup" +./build-app.sh > /dev/null 2>&1 +SETUP_APP="${PROJECT_DIR}/macos/VoxtypeSetup/.build/VoxtypeSetup.app" + +# Verify Swift apps exist +if [[ ! -d "$MENUBAR_APP" ]]; then + echo -e "${RED}Error: VoxtypeMenubar.app not found${NC}" + exit 1 fi -# Create temporary directory for DMG contents -TEMP_DIR=$(mktemp -d) -trap "rm -rf $TEMP_DIR" EXIT +if [[ ! -d "$SETUP_APP" ]]; then + echo -e "${RED}Error: VoxtypeSetup.app not found${NC}" + exit 1 +fi -# Copy binary -cp "$BINARY" "$TEMP_DIR/voxtype" -chmod +x "$TEMP_DIR/voxtype" +# Create app bundle structure +echo -e "${YELLOW}Creating Voxtype.app bundle...${NC}" +rm -rf "$APP_DIR" +mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/Resources" -# Create README -cat > "$TEMP_DIR/README.txt" << 'EOF' -Voxtype - Push-to-talk voice-to-text for macOS +# Copy the main voxtype binary +cp "$BINARY" "$APP_DIR/Contents/MacOS/voxtype" +chmod +x "$APP_DIR/Contents/MacOS/voxtype" -Installation: - 1. Drag 'voxtype' to /usr/local/bin or your preferred location - 2. Grant Accessibility permissions when prompted - 3. Run: voxtype setup launchd +# Copy VoxtypeMenubar.app +cp -R "$MENUBAR_APP" "$APP_DIR/Contents/MacOS/" -Quick Start: - voxtype daemon - Start the daemon - voxtype setup launchd - Install as LaunchAgent (auto-start) - voxtype setup model - Download/select Whisper model - voxtype --help - Show all options +# Copy VoxtypeSetup.app +cp -R "$SETUP_APP" "$APP_DIR/Contents/MacOS/" -For more information, visit: https://voxtype.io +# Copy engine icons for notifications +if [[ -f "${PROJECT_DIR}/assets/engines/parakeet.png" ]]; then + cp "${PROJECT_DIR}/assets/engines/parakeet.png" "$APP_DIR/Contents/Resources/" +fi +if [[ -f "${PROJECT_DIR}/assets/engines/whisper.png" ]]; then + cp "${PROJECT_DIR}/assets/engines/whisper.png" "$APP_DIR/Contents/Resources/" +fi + +# Copy app icon if it exists +if [[ -f "${PROJECT_DIR}/assets/icon.icns" ]]; then + cp "${PROJECT_DIR}/assets/icon.icns" "$APP_DIR/Contents/Resources/AppIcon.icns" +fi + +# Create Info.plist +cat > "$APP_DIR/Contents/Info.plist" << EOF + + + + + CFBundleExecutable + voxtype + CFBundleIdentifier + io.voxtype.app + CFBundleName + Voxtype + CFBundleDisplayName + Voxtype + CFBundleVersion + ${VERSION} + CFBundleShortVersionString + ${VERSION} + CFBundlePackageType + APPL + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + LSUIElement + + NSMicrophoneUsageDescription + Voxtype needs microphone access to record your voice for transcription. + NSAppleEventsUsageDescription + Voxtype uses AppleScript to type transcribed text into applications. + + EOF -# Remove existing DMG if present -rm -f "$DMG_PATH" +echo -e "${GREEN}App bundle created:${NC}" +echo " $APP_DIR" +du -sh "$APP_DIR" +echo # Create DMG echo -e "${YELLOW}Creating DMG...${NC}" -create-dmg \ - --volname "Voxtype $VERSION" \ - --volicon "packaging/macos/icon.icns" \ - --background "packaging/macos/dmg-background.png" \ - --window-pos 200 120 \ - --window-size 600 400 \ - --icon-size 100 \ - --icon "voxtype" 175 190 \ - --icon "README.txt" 425 190 \ - --hide-extension "voxtype" \ - --app-drop-link 425 190 \ - "$DMG_PATH" \ - "$TEMP_DIR" 2>/dev/null || { - # If create-dmg fails (e.g., missing background), create simple DMG - echo -e "${YELLOW}Creating simple DMG (no custom background)...${NC}" - hdiutil create -volname "Voxtype $VERSION" \ - -srcfolder "$TEMP_DIR" \ - -ov -format UDZO \ - "$DMG_PATH" - } +rm -f "$DMG_PATH" + +hdiutil create -volname "Voxtype ${VERSION}" \ + -srcfolder "$APP_DIR" \ + -ov -format UDZO \ + "$DMG_PATH" # Get DMG size SIZE=$(du -h "$DMG_PATH" | cut -f1) @@ -109,8 +165,12 @@ echo echo "SHA256 checksum:" shasum -a 256 "$DMG_PATH" +# Update the checksum file +CHECKSUM=$(shasum -a 256 "$DMG_PATH" | cut -d' ' -f1) +echo "${CHECKSUM} $(basename "$DMG_PATH")" > "${RELEASES_DIR}/macos-SHA256SUMS.txt" + echo echo "Next steps:" -echo " 1. Test the DMG by mounting it" -echo " 2. Upload to GitHub release" -echo " 3. Update Homebrew cask formula" +echo " 1. Test the DMG: open '$DMG_PATH'" +echo " 2. Update Homebrew cask with new SHA256: $CHECKSUM" +echo " 3. Upload to GitHub release" From 6c08105ccd66669816265328dae2cbb27a136973 Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:42:05 -0500 Subject: [PATCH 25/33] Open Settings to Permissions pane on first Homebrew install - VoxtypeSetup opens to Permissions tab on first launch - Cask starts daemon and opens Settings for permission granting - Updated caveats to guide user through permission setup Co-Authored-By: Claude Opus 4.5 --- .../Sources/VoxtypeSetupApp.swift | 16 ++++++++++++ packaging/homebrew/Casks/voxtype.rb | 26 ++++++++----------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift index 41f96527..db5d14ec 100644 --- a/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift +++ b/macos/VoxtypeSetup/Sources/VoxtypeSetupApp.swift @@ -29,6 +29,22 @@ struct SettingsView: View { .padding() } .navigationTitle("Voxtype Settings") + .onAppear { + // On first launch, go to Permissions so user can grant access + if isFirstLaunch() { + selectedSection = .permissions + } + } + } + + private func isFirstLaunch() -> Bool { + let key = "HasLaunchedBefore" + let hasLaunched = UserDefaults.standard.bool(forKey: key) + if !hasLaunched { + UserDefaults.standard.set(true, forKey: key) + return true + } + return false } } diff --git a/packaging/homebrew/Casks/voxtype.rb b/packaging/homebrew/Casks/voxtype.rb index ab647a3b..c80e77ff 100644 --- a/packaging/homebrew/Casks/voxtype.rb +++ b/packaging/homebrew/Casks/voxtype.rb @@ -1,6 +1,6 @@ cask "voxtype" do version "0.6.0-rc1" - sha256 "b98f426c74f35cc769191ad9c427078f64695017a0273bf323b259488962bb30" + sha256 "791963b523e84c3569cae2e64fae02bb782e9ce1bf0f244b8f45a8149ad80dd8" url "file:///Users/pete/workspace/voxtype/releases/0.6.0-rc1/Voxtype-0.6.0-rc1-macos-arm64.dmg" name "Voxtype" @@ -86,11 +86,12 @@ args: ["setup", "--download", "--model", "parakeet-tdt-0.6b-v3-int8"], print_stdout: true - # Now load the LaunchAgent (after setup is complete) + # Load the LaunchAgent to start the daemon + # It will work once user grants permissions system_command "/bin/launchctl", args: ["load", plist_path] - # Launch the menubar app - system_command "/usr/bin/open", args: ["/Applications/Voxtype.app/Contents/MacOS/VoxtypeMenubar.app"] + # Launch Settings app to Permissions pane so user can grant access + system_command "/usr/bin/open", args: ["/Applications/Voxtype.app/Contents/MacOS/VoxtypeSetup.app"] end uninstall_postflight do @@ -112,20 +113,15 @@ ] caveats <<~EOS - Voxtype is installed and running! + Voxtype is installed and the daemon is running! - The Parakeet speech model was downloaded automatically. - Look for the microphone icon in your menu bar. + The Settings app opened to help you grant permissions: + 1. Click "Grant Access" for Input Monitoring (hotkey detection) + 2. Click "Grant Access" for Microphone (recording) + + Once permissions are granted, hold Right Option to record. If prompted "Voxtype was blocked", go to: System Settings > Privacy & Security > click "Open Anyway" - - To use hotkeys, grant Input Monitoring permission: - System Settings > Privacy & Security > Input Monitoring - - Default hotkey: Right Option (hold to record, release to transcribe) - - Settings: Click menu bar icon > Settings - CLI help: voxtype --help EOS end From c5851fc415c78ddc7c15892ab68718a421e5a51b Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 13:53:28 -0500 Subject: [PATCH 26/33] Add "Show in Menu Bar" toggle to Settings app - New toggle in General settings to show/hide menubar icon - Launches VoxtypeMenubar.app when enabled - Quits menubar app when disabled Co-Authored-By: Claude Opus 4.5 --- .../Settings/GeneralSettingsView.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift index 15cde6d8..fcf2d3ce 100644 --- a/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift +++ b/macos/VoxtypeSetup/Sources/Settings/GeneralSettingsView.swift @@ -1,10 +1,12 @@ import SwiftUI +import AppKit struct GeneralSettingsView: View { @State private var selectedEngine: String = "parakeet" @State private var hotkeyMode: String = "push_to_talk" @State private var hotkey: String = "RIGHTALT" @State private var daemonRunning: Bool = false + @State private var menubarRunning: Bool = false @State private var needsRestart: Bool = false var body: some View { @@ -90,11 +92,29 @@ struct GeneralSettingsView: View { } header: { Text("Daemon Status") } + + Section { + Toggle("Show in Menu Bar", isOn: $menubarRunning) + .onChange(of: menubarRunning) { newValue in + if newValue { + launchMenubar() + } else { + quitMenubar() + } + } + + Text("Display a status icon in the menu bar for quick access.") + .font(.caption) + .foregroundColor(.secondary) + } header: { + Text("Menu Bar") + } } .formStyle(.grouped) .onAppear { loadSettings() checkDaemonStatus() + checkMenubarStatus() } } @@ -132,4 +152,32 @@ struct GeneralSettingsView: View { } } } + + private func checkMenubarStatus() { + let task = Process() + task.launchPath = "/usr/bin/pgrep" + task.arguments = ["-x", "VoxtypeMenubar"] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + menubarRunning = (task.terminationStatus == 0) + } + + private func launchMenubar() { + let menubarPath = "/Applications/Voxtype.app/Contents/MacOS/VoxtypeMenubar.app" + if FileManager.default.fileExists(atPath: menubarPath) { + NSWorkspace.shared.open(URL(fileURLWithPath: menubarPath)) + } + } + + private func quitMenubar() { + let task = Process() + task.launchPath = "/usr/bin/pkill" + task.arguments = ["-x", "VoxtypeMenubar"] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + try? task.run() + task.waitUntilExit() + } } From ffa0e6e50aebc929bed580b19c4cdff7a58a92eb Mon Sep 17 00:00:00 2001 From: Peter Jackson Date: Sat, 31 Jan 2026 20:53:22 -0500 Subject: [PATCH 27/33] Bump version to 0.6.0-rc.2, document 7-binary release process - Update version to 0.6.0-rc.2 - Document all 7 required Linux binaries: avx2, avx512, vulkan, parakeet-avx2, parakeet-avx512, parakeet-cuda, parakeet-rocm - Update build instructions to include parakeet-cuda (Docker/NVIDIA) and parakeet-rocm (local/AMD) builds - Regenerate Cargo.lock --- CLAUDE.md | 52 +++++---- Cargo.lock | 324 +++++++++++++++++++++++++++-------------------------- Cargo.toml | 2 +- 3 files changed, 199 insertions(+), 179 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 03dedfe9..04f56281 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -414,23 +414,26 @@ Building on modern CPUs (Zen 4, etc.) can leak AVX-512/GFNI instructions into bi ### Build Strategy -**Whisper Binaries:** +A full release requires **7 Linux binaries**: 3 Whisper variants and 4 Parakeet variants. + +**Whisper Binaries (3):** | Binary | Build Location | Why | |--------|---------------|-----| -| AVX2 | Docker on remote pre-AVX-512 server | Clean toolchain, no AVX-512 contamination | -| Vulkan | Docker on remote pre-AVX-512 server | GPU build on CPU without AVX-512 | -| AVX512 | Local machine | Requires AVX-512 capable host | +| avx2 | Docker on remote pre-AVX-512 server | Clean toolchain, no AVX-512 contamination | +| avx512 | Local machine | Requires AVX-512 capable host | +| vulkan | Docker on remote pre-AVX-512 server | GPU build without AVX-512 contamination | -**Parakeet Binaries (Experimental):** +**Parakeet Binaries (4):** | Binary | Build Location | Why | |--------|---------------|-----| | parakeet-avx2 | Docker on remote pre-AVX-512 server | Wide CPU compatibility | | parakeet-avx512 | Local machine | Best CPU performance | -| parakeet-cuda | Docker on remote server with NVIDIA GPU | GPU acceleration | +| parakeet-cuda | Docker on remote server with NVIDIA GPU | NVIDIA GPU acceleration | +| parakeet-rocm | Local machine with AMD GPU | AMD GPU acceleration | -Note: Parakeet binaries include bundled ONNX Runtime which contains AVX-512 instructions, but ONNX Runtime uses runtime CPU detection and falls back gracefully on older CPUs. +Note: Parakeet binaries include bundled ONNX Runtime which may contain AVX-512 instructions, but ONNX Runtime uses runtime CPU detection and falls back gracefully on older CPUs. ### GPU Feature Flags @@ -495,22 +498,23 @@ Stale build artifacts cause two categories of failures: # Set version export VERSION=0.5.0 -# 1. Build Whisper binaries (AVX2 + Vulkan) on remote server +# 1. Build Whisper + Parakeet CPU binaries on remote server (no AVX-512) docker context use -docker compose -f docker-compose.build.yml build --no-cache avx2 vulkan -docker compose -f docker-compose.build.yml up avx2 vulkan +docker compose -f docker-compose.build.yml build --no-cache avx2 vulkan parakeet-avx2 +docker compose -f docker-compose.build.yml up avx2 vulkan parakeet-avx2 -# 2. Build Parakeet binaries on remote server -docker compose -f docker-compose.build.yml build --no-cache parakeet-avx2 -docker compose -f docker-compose.build.yml up parakeet-avx2 +# 2. Build Parakeet CUDA on remote server (requires NVIDIA GPU) +docker compose -f docker-compose.build.yml build --no-cache parakeet-cuda +docker compose -f docker-compose.build.yml up parakeet-cuda -# 3. Copy binaries from remote Docker volumes to local +# 3. Copy binaries from remote Docker containers to local mkdir -p releases/${VERSION} -docker run --rm -v $(pwd)/releases/${VERSION}:/test ubuntu:24.04 ls /test # verify -# Use tar pipe to copy from remote Docker volume: -docker run --rm -v $(pwd)/releases/${VERSION}:/src ubuntu:24.04 tar -cf - -C /src . | tar -xf - -C releases/${VERSION}/ +docker cp macos-release-avx2-1:/output/. releases/${VERSION}/ +docker cp macos-release-vulkan-1:/output/. releases/${VERSION}/ +docker cp macos-release-parakeet-avx2-1:/output/. releases/${VERSION}/ +docker cp macos-release-parakeet-cuda-1:/output/. releases/${VERSION}/ -# 4. Build AVX-512 binaries locally (requires AVX-512 capable CPU) +# 4. Build local binaries (requires AVX-512 CPU and AMD GPU) docker context use default # Whisper AVX-512 @@ -521,6 +525,10 @@ cp target/release/voxtype releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-av cargo clean && RUSTFLAGS="-C target-cpu=native" cargo build --release --features parakeet cp target/release/voxtype releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx512 +# Parakeet ROCm (requires AMD GPU) +cargo clean && RUSTFLAGS="-C target-cpu=native" cargo build --release --features parakeet-rocm +cp target/release/voxtype releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-rocm + # 5. VERIFY VERSIONS before uploading (critical!) for bin in releases/${VERSION}/voxtype-*; do echo -n "$(basename $bin): "; $bin --version @@ -532,17 +540,19 @@ done ### Version Verification Checklist -**Before uploading any release, verify ALL binaries report the correct version:** +**Before uploading any release, verify ALL 7 binaries report the correct version:** ```bash -# Whisper binaries +# Whisper binaries (3) releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx2 --version releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx512 --version releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-vulkan --version -# Parakeet binaries (experimental) +# Parakeet binaries (4) releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 --version releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx512 --version +releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-cuda --version +releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-rocm --version ``` If versions don't match, the Docker cache is stale. Rebuild with `--no-cache`. diff --git a/Cargo.lock b/Cargo.lock index 0fb893c2..9d6e2a9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -117,7 +117,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -163,9 +163,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bindgen" @@ -184,7 +184,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -202,7 +202,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -249,9 +249,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -301,9 +301,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.47" +version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" dependencies = [ "find-msvc-tools", "jobserver", @@ -361,9 +361,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" dependencies = [ "clap_builder", "clap_derive", @@ -371,9 +371,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" dependencies = [ "anstream", "anstyle", @@ -383,21 +383,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clap_mangen" @@ -411,9 +411,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -661,7 +661,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -672,7 +672,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -702,7 +702,7 @@ dependencies = [ [[package]] name = "deranged" - +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ @@ -727,7 +727,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -737,7 +737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -815,7 +815,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -838,7 +838,7 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -931,27 +931,26 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -990,7 +989,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1075,7 +1074,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1185,9 +1184,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -1272,7 +1271,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1351,7 +1350,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1380,9 +1379,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hmac-sha256" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" +checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d" [[package]] name = "hound" @@ -1522,9 +1521,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -1627,9 +1626,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1698,9 +1697,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -1734,13 +1733,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.7.0", ] [[package]] @@ -1785,15 +1784,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lzma-rust2" -version = "0.15.6" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" [[package]] name = "mac-notification-sys" @@ -1914,9 +1913,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1942,7 +1941,7 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -1962,7 +1961,7 @@ dependencies = [ "objc2-foundation", "once_cell", "png", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -1985,9 +1984,9 @@ dependencies = [ [[package]] name = "ndarray" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7c9125e8f6f10c9da3aad044cc918cf8784fa34de857b1aa68038eb05a50a9" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -2137,7 +2136,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2187,7 +2186,7 @@ dependencies = [ "proc-macro-crate 3.4.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2340,7 +2339,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2451,7 +2450,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -2521,15 +2520,15 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" dependencies = [ "portable-atomic", ] @@ -2565,7 +2564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -2603,7 +2602,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] @@ -2632,18 +2631,18 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -2682,9 +2681,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -2757,13 +2756,22 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -2774,9 +2782,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -2816,7 +2824,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2870,9 +2878,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -2898,18 +2906,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -3008,7 +3016,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3050,10 +3058,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -3065,9 +3074,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" @@ -3077,9 +3086,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -3150,9 +3159,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -3167,7 +3176,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3230,7 +3239,7 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3247,9 +3256,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -3269,11 +3278,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -3284,18 +3293,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3363,7 +3372,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -3371,13 +3380,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.0", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -3394,7 +3403,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -3420,9 +3429,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -3453,30 +3462,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow 0.7.14", ] [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -3491,14 +3500,14 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -3517,9 +3526,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -3560,7 +3569,7 @@ dependencies = [ "objc2-foundation", "once_cell", "png", - "thiserror 2.0.17", + "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -3701,7 +3710,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "voxtype" -version = "0.6.0-rc.1" +version = "0.6.0-rc.2" dependencies = [ "anyhow", "async-trait", @@ -3759,18 +3768,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -3781,11 +3790,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3794,9 +3804,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3804,31 +3814,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -3999,7 +4009,7 @@ checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4010,7 +4020,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4385,9 +4395,9 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -4451,28 +4461,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.33" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +checksum = "7456cf00f0685ad319c5b1693f291a650eaf345e941d082fc4e03df8a03996ac" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.33" +version = "0.8.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +checksum = "1328722bbf2115db7e19d69ebcc15e795719e2d66b60827c6a69a117365e37a0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] @@ -4492,7 +4502,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", "synstructure", ] @@ -4532,11 +4542,11 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.114", ] [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" diff --git a/Cargo.toml b/Cargo.toml index 92c4748d..7df02aed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["xtask"] [package] name = "voxtype" -version = "0.6.0-rc.1" +version = "0.6.0-rc.2" edition = "2021" authors = ["Peter Jackson", "Jean-Paul van Tillo", "Máté Rémiás", "Rob Zolkos", "Dan Heuckeroth", "Igor Warzocha", "Julian Kaiser", "Kevin Miller", "konnsim", "reisset", "Zubair", "Loki Coyote", "Christopher Albert", "André Silva", "goodroot", "Chmouel Boudjnah", "Alexander Bosu-Kellett", "ayoahha", "Thinh Vu"] description = "Push-to-talk voice-to-text for Wayland" From ad616e05d16ce626a329e509309e8343d27c243e Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Tue, 3 Feb 2026 20:02:29 +0100 Subject: [PATCH 28/33] Fix CI workflow issues for macOS PR - Fix dtolnay/rust-action -> dtolnay/rust-toolchain (non-existent action) - Add chmod 755 in Dockerfiles to fix permission errors on mounted volumes - Remove redundant chmod from build-linux.yml (Docker handles it now) - Add missing GTK/X11 dev dependencies to test-packages.yml and AVX-512 builds - Add protobuf deps for Parakeet AVX-512 build --- .github/workflows/build-linux.yml | 14 +++++++------- .github/workflows/build-macos.yml | 2 +- .github/workflows/test-packages.yml | 7 ++++++- Dockerfile.build | 1 + Dockerfile.parakeet | 1 + Dockerfile.vulkan | 1 + 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index cb26e2ca..11a4f125 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -37,7 +37,6 @@ jobs: - name: Verify binary run: | VERSION=${{ steps.version.outputs.version }} - chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx2 releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-avx2 --version - name: Upload artifact @@ -71,7 +70,6 @@ jobs: - name: Verify binary run: | VERSION=${{ steps.version.outputs.version }} - chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-vulkan releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-vulkan --version - name: Upload artifact @@ -105,7 +103,6 @@ jobs: - name: Verify binary run: | VERSION=${{ steps.version.outputs.version }} - chmod +x releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 releases/${VERSION}/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 --version - name: Upload artifact @@ -143,13 +140,14 @@ jobs: - name: Install Rust if: steps.check-avx512.outputs.supported == 'true' - uses: dtolnay/rust-action@stable + uses: dtolnay/rust-toolchain@stable - name: Install dependencies if: steps.check-avx512.outputs.supported == 'true' run: | sudo apt-get update - sudo apt-get install -y libasound2-dev clang cmake + sudo apt-get install -y libasound2-dev libclang-dev cmake \ + libgtk-3-dev libglib2.0-dev libx11-dev libxi-dev libxtst-dev - name: Build AVX-512 binary if: steps.check-avx512.outputs.supported == 'true' @@ -203,13 +201,15 @@ jobs: - name: Install Rust if: steps.check-avx512.outputs.supported == 'true' - uses: dtolnay/rust-action@stable + uses: dtolnay/rust-toolchain@stable - name: Install dependencies if: steps.check-avx512.outputs.supported == 'true' run: | sudo apt-get update - sudo apt-get install -y libasound2-dev clang cmake + sudo apt-get install -y libasound2-dev libclang-dev cmake \ + libgtk-3-dev libglib2.0-dev libx11-dev libxi-dev libxtst-dev \ + libssl-dev protobuf-compiler libprotobuf-dev - name: Build Parakeet AVX-512 binary if: steps.check-avx512.outputs.supported == 'true' diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 51ae0a1b..6a595ad3 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -18,7 +18,7 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust - uses: dtolnay/rust-action@stable + uses: dtolnay/rust-toolchain@stable - name: Install Rust targets run: | diff --git a/.github/workflows/test-packages.yml b/.github/workflows/test-packages.yml index 854183cd..248ddf32 100644 --- a/.github/workflows/test-packages.yml +++ b/.github/workflows/test-packages.yml @@ -43,7 +43,12 @@ jobs: cmake \ ruby \ ruby-dev \ - build-essential + build-essential \ + libgtk-3-dev \ + libglib2.0-dev \ + libx11-dev \ + libxi-dev \ + libxtst-dev sudo gem install fpm - name: Cache cargo registry diff --git a/Dockerfile.build b/Dockerfile.build index c727fa1a..e10bf34c 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -80,5 +80,6 @@ RUN echo "=== Verifying AVX2 binary ===" \ # Output stage - copy binary to /output volume CMD mkdir -p /output \ && cp /tmp/voxtype-avx2 /output/voxtype-${VERSION}-linux-x86_64-avx2 \ + && chmod 755 /output/voxtype-${VERSION}-linux-x86_64-avx2 \ && echo "Binary copied to /output:" \ && ls -la /output/voxtype-* diff --git a/Dockerfile.parakeet b/Dockerfile.parakeet index 11baf34b..d1f49d61 100644 --- a/Dockerfile.parakeet +++ b/Dockerfile.parakeet @@ -89,5 +89,6 @@ RUN echo "=== Verifying Parakeet AVX2 binary ===" \ # Output stage CMD mkdir -p /output \ && cp /tmp/voxtype-parakeet-avx2 /output/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 \ + && chmod 755 /output/voxtype-${VERSION}-linux-x86_64-parakeet-avx2 \ && echo "Binary copied to /output:" \ && ls -la /output/voxtype-* diff --git a/Dockerfile.vulkan b/Dockerfile.vulkan index f76d2531..44257e5e 100644 --- a/Dockerfile.vulkan +++ b/Dockerfile.vulkan @@ -87,5 +87,6 @@ RUN echo "=== Verifying Vulkan binary ===" \ # Output stage - copy binary to /output volume CMD mkdir -p /output \ && cp /tmp/voxtype-vulkan /output/voxtype-${VERSION}-linux-x86_64-vulkan \ + && chmod 755 /output/voxtype-${VERSION}-linux-x86_64-vulkan \ && echo "Binary copied to /output:" \ && ls -la /output/voxtype-*-vulkan From e71b76eda8653ac755abe8beac1d609641518b90 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 4 Feb 2026 19:24:45 +0100 Subject: [PATCH 29/33] Add FN/Function/Globe key support for macOS hotkeys --- src/hotkey_macos.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/hotkey_macos.rs b/src/hotkey_macos.rs index a305f7d2..85f1f139 100644 --- a/src/hotkey_macos.rs +++ b/src/hotkey_macos.rs @@ -186,6 +186,7 @@ fn parse_key_name(name: &str) -> Option { "PAUSE" => Some(Key::Pause), "SCROLLLOCK" => Some(Key::ScrollLock), "PRINTSCREEN" => Some(Key::PrintScreen), + "FN" | "FUNCTION" | "GLOBE" => Some(Key::Function), // Letters (for completeness, though unusual for hotkeys) "A" => Some(Key::KeyA), From cbc3e19afa4311aa631282ac5655bdb8286f2b07 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 4 Feb 2026 19:33:51 +0100 Subject: [PATCH 30/33] Add voxtype setup app-bundle for macOS (recommended over launchd) Creates /Applications/Voxtype.app bundle and adds to Login Items. This is the recommended way to run voxtype on macOS because: - App bundles can be granted Accessibility, Input Monitoring, and Microphone permissions properly - Login Items inherit these permissions (launchd services don't) - Clean auto-start on login without extra wrapper scripts - Runs both daemon and menu bar icon Usage: voxtype setup app-bundle # Install voxtype setup app-bundle --status # Check status voxtype setup app-bundle --uninstall The launchd option is kept for users who don't need microphone (e.g., using remote transcription), but app-bundle is recommended. --- src/cli.rs | 17 ++ src/main.rs | 10 ++ src/setup/app_bundle.rs | 338 ++++++++++++++++++++++++++++++++++++++++ src/setup/mod.rs | 2 + 4 files changed, 367 insertions(+) create mode 100644 src/setup/app_bundle.rs diff --git a/src/cli.rs b/src/cli.rs index 65148347..766ef0b8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -368,6 +368,8 @@ pub enum SetupAction { }, /// Install voxtype as a LaunchAgent (macOS) + /// Note: launchd services don't receive microphone permissions. + /// Use 'app-bundle' instead for full functionality. #[cfg(target_os = "macos")] Launchd { /// Uninstall the service instead of installing @@ -379,6 +381,21 @@ pub enum SetupAction { status: bool, }, + /// Install Voxtype.app bundle with Login Items (macOS, recommended) + /// Creates /Applications/Voxtype.app and adds to Login Items. + /// This method properly receives Accessibility, Input Monitoring, + /// and Microphone permissions (unlike launchd). + #[cfg(target_os = "macos")] + AppBundle { + /// Uninstall the app bundle + #[arg(long)] + uninstall: bool, + + /// Show installation status + #[arg(long)] + status: bool, + }, + /// Set up Hammerspoon hotkey integration (macOS) #[cfg(target_os = "macos")] Hammerspoon { diff --git a/src/main.rs b/src/main.rs index 0889cac9..cfd1b39f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -208,6 +208,16 @@ async fn main() -> anyhow::Result<()> { } } #[cfg(target_os = "macos")] + Some(SetupAction::AppBundle { uninstall, status }) => { + if status { + setup::app_bundle::status().await?; + } else if uninstall { + setup::app_bundle::uninstall().await?; + } else { + setup::app_bundle::install().await?; + } + } + #[cfg(target_os = "macos")] Some(SetupAction::Hammerspoon { install, show, hotkey, toggle }) => { setup::hammerspoon::run(install, show, &hotkey, toggle).await?; } diff --git a/src/setup/app_bundle.rs b/src/setup/app_bundle.rs new file mode 100644 index 00000000..7ad127ea --- /dev/null +++ b/src/setup/app_bundle.rs @@ -0,0 +1,338 @@ +//! macOS App Bundle creation and Login Items setup +//! +//! Creates a proper macOS app bundle for voxtype and manages Login Items. +//! This is preferred over launchd for the daemon because: +//! - App bundles can be granted Accessibility, Input Monitoring, and Microphone permissions +//! - Login Items inherit these permissions correctly (launchd services don't get mic access) + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::process::Command; + +use super::{get_voxtype_path, print_failure, print_info, print_success, print_warning}; + +const APP_NAME: &str = "Voxtype.app"; +const BUNDLE_ID: &str = "io.voxtype.daemon"; + +/// Get the path to the app bundle +pub fn app_bundle_path() -> PathBuf { + PathBuf::from("/Applications").join(APP_NAME) +} + +/// Get the path to the binary inside the app bundle +pub fn app_binary_path() -> PathBuf { + app_bundle_path() + .join("Contents") + .join("MacOS") + .join("voxtype") +} + +/// Get the path to the logs directory +fn logs_dir() -> Option { + dirs::home_dir().map(|home| home.join("Library/Logs/voxtype")) +} + +/// Generate Info.plist content +fn generate_info_plist(version: &str) -> String { + format!( + r#" + + + + CFBundleExecutable + voxtype + CFBundleIdentifier + {bundle_id} + CFBundleName + Voxtype + CFBundleDisplayName + Voxtype + CFBundleVersion + {version} + CFBundleShortVersionString + {version} + CFBundlePackageType + APPL + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSMicrophoneUsageDescription + Voxtype needs microphone access for speech-to-text transcription. + NSAppleEventsUsageDescription + Voxtype needs accessibility access to type transcribed text. + + +"#, + bundle_id = BUNDLE_ID, + version = version, + ) +} + +/// Generate wrapper script that runs the daemon and menubar +fn generate_wrapper_script() -> String { + let logs = logs_dir().unwrap_or_else(|| PathBuf::from("/tmp/voxtype")); + format!( + r#"#!/bin/bash +# Voxtype app wrapper - starts daemon and menu bar + +# Kill any existing instances +pkill -9 -f "voxtype daemon" 2>/dev/null +pkill -9 -f "voxtype menubar" 2>/dev/null +rm -f /tmp/voxtype/voxtype.lock + +# Create logs directory +mkdir -p "{logs}" + +# Get the directory where this script is located +DIR="$(cd "$(dirname "$0")" && pwd)" + +# Start daemon in background with logging +"$DIR/voxtype-bin" daemon >> "{logs}/stdout.log" 2>> "{logs}/stderr.log" & + +# Start menubar (foreground keeps app alive and shows menu bar icon) +exec "$DIR/voxtype-bin" menubar +"#, + logs = logs.display() + ) +} + +/// Create the app bundle +pub fn create_app_bundle() -> anyhow::Result<()> { + let app_path = app_bundle_path(); + let contents_path = app_path.join("Contents"); + let macos_path = contents_path.join("MacOS"); + + // Create directory structure + fs::create_dir_all(&macos_path)?; + + // Get version from current binary + let version = env!("CARGO_PKG_VERSION"); + + // Write Info.plist + fs::write(contents_path.join("Info.plist"), generate_info_plist(version))?; + + // Copy the current voxtype binary + let source_binary = get_voxtype_path(); + let dest_binary = macos_path.join("voxtype-bin"); + fs::copy(&source_binary, &dest_binary)?; + + // Make binary executable + let mut perms = fs::metadata(&dest_binary)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&dest_binary, perms)?; + + // Create wrapper script as main executable + let wrapper_path = macos_path.join("voxtype"); + fs::write(&wrapper_path, generate_wrapper_script())?; + let mut perms = fs::metadata(&wrapper_path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(&wrapper_path, perms)?; + + // Code sign the app bundle (ad-hoc) + let _ = Command::new("codesign") + .args(["--force", "--deep", "--sign", "-", app_path.to_str().unwrap()]) + .output(); + + Ok(()) +} + +/// Add app to Login Items +pub fn add_to_login_items() -> anyhow::Result { + let app_path = app_bundle_path(); + let script = format!( + r#"tell application "System Events" + if not (exists login item "Voxtype") then + make login item at end with properties {{path:"{}", hidden:true}} + end if +end tell"#, + app_path.display() + ); + + let output = Command::new("osascript") + .args(["-e", &script]) + .output()?; + + Ok(output.status.success()) +} + +/// Remove app from Login Items +pub fn remove_from_login_items() -> anyhow::Result { + let script = r#"tell application "System Events" + if exists login item "Voxtype" then + delete login item "Voxtype" + end if +end tell"#; + + let output = Command::new("osascript") + .args(["-e", script]) + .output()?; + + Ok(output.status.success()) +} + +/// Check if app is in Login Items +pub fn is_in_login_items() -> bool { + let script = r#"tell application "System Events" + return exists login item "Voxtype" +end tell"#; + + Command::new("osascript") + .args(["-e", script]) + .output() + .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "true") + .unwrap_or(false) +} + +/// Remove the app bundle +pub fn remove_app_bundle() -> anyhow::Result<()> { + let app_path = app_bundle_path(); + if app_path.exists() { + fs::remove_dir_all(&app_path)?; + } + Ok(()) +} + +/// Open System Settings to the relevant privacy pane +pub fn open_privacy_settings(pane: &str) -> anyhow::Result<()> { + let url = match pane { + "accessibility" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", + "input" => "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent", + "microphone" => "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone", + "login" => "x-apple.systempreferences:com.apple.LoginItems-Settings.extension", + _ => return Err(anyhow::anyhow!("Unknown pane: {}", pane)), + }; + + Command::new("open").arg(url).spawn()?; + Ok(()) +} + +/// Install the app bundle and set up Login Items +pub async fn install() -> anyhow::Result<()> { + println!("Installing Voxtype.app...\n"); + + // Create logs directory + if let Some(logs) = logs_dir() { + fs::create_dir_all(&logs)?; + print_success(&format!("Logs directory: {:?}", logs)); + } + + // Create app bundle + create_app_bundle()?; + print_success(&format!("Created: {:?}", app_bundle_path())); + + // Add to Login Items + if add_to_login_items()? { + print_success("Added to Login Items"); + } else { + print_warning("Could not add to Login Items automatically"); + print_info("Add manually: System Settings > General > Login Items"); + } + + println!("\n---"); + println!("\x1b[32m✓ Installation complete!\x1b[0m"); + println!(); + println!("\x1b[1mIMPORTANT: Grant permissions to Voxtype.app:\x1b[0m"); + println!(); + println!(" 1. System Settings > Privacy & Security > \x1b[1mAccessibility\x1b[0m"); + println!(" Add and enable Voxtype"); + println!(); + println!(" 2. System Settings > Privacy & Security > \x1b[1mInput Monitoring\x1b[0m"); + println!(" Add and enable Voxtype"); + println!(); + println!(" 3. System Settings > Privacy & Security > \x1b[1mMicrophone\x1b[0m"); + println!(" Voxtype should appear after first use - enable it"); + println!(); + println!("To start now:"); + println!(" open /Applications/Voxtype.app"); + println!(); + println!("Voxtype will start automatically on login."); + + Ok(()) +} + +/// Uninstall the app bundle and remove from Login Items +pub async fn uninstall() -> anyhow::Result<()> { + println!("Uninstalling Voxtype.app...\n"); + + // Stop any running instance + let _ = Command::new("pkill") + .args(["-9", "-f", "Voxtype.app"]) + .status(); + + // Remove from Login Items + if remove_from_login_items()? { + print_success("Removed from Login Items"); + } + + // Remove app bundle + if app_bundle_path().exists() { + remove_app_bundle()?; + print_success("Removed Voxtype.app"); + } else { + print_info("Voxtype.app was not installed"); + } + + println!("\n---"); + println!("\x1b[32m✓ Uninstallation complete!\x1b[0m"); + + Ok(()) +} + +/// Show installation status +pub async fn status() -> anyhow::Result<()> { + println!("Voxtype.app Status\n"); + println!("==================\n"); + + // Check app bundle + if app_bundle_path().exists() { + print_success(&format!("App installed: {:?}", app_bundle_path())); + } else { + print_failure("Voxtype.app not installed"); + print_info("Install with: voxtype setup app-bundle"); + return Ok(()); + } + + // Check Login Items + if is_in_login_items() { + print_success("In Login Items (will start on login)"); + } else { + print_warning("Not in Login Items"); + print_info("Add with: voxtype setup app-bundle"); + } + + // Check if running + let output = Command::new("pgrep") + .args(["-f", "Voxtype.app"]) + .output(); + + match output { + Ok(out) if out.status.success() => { + let pid = String::from_utf8_lossy(&out.stdout); + print_success(&format!("Running (PID: {})", pid.trim())); + } + _ => { + print_info("Not currently running"); + print_info("Start with: open /Applications/Voxtype.app"); + } + } + + // Show log locations + if let Some(logs) = logs_dir() { + println!("\nLogs:"); + let stdout_log = logs.join("stdout.log"); + let stderr_log = logs.join("stderr.log"); + + if stdout_log.exists() { + let size = fs::metadata(&stdout_log).map(|m| m.len()).unwrap_or(0); + println!(" stdout: {:?} ({} bytes)", stdout_log, size); + } + if stderr_log.exists() { + let size = fs::metadata(&stderr_log).map(|m| m.len()).unwrap_or(0); + println!(" stderr: {:?} ({} bytes)", stderr_log, size); + } + } + + Ok(()) +} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 12ab61f5..5ec5d2c2 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -13,6 +13,8 @@ pub mod compositor; pub mod dms; pub mod gpu; #[cfg(target_os = "macos")] +pub mod app_bundle; +#[cfg(target_os = "macos")] pub mod hammerspoon; pub mod launchd; #[cfg(target_os = "macos")] From c7dea1cb2069659a3870533bbc03b6fba81ea0b0 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Thu, 5 Feb 2026 09:07:06 +0100 Subject: [PATCH 31/33] Unify macOS setup: macos.rs uses app_bundle, deprecate launchd - macos.rs wizard now delegates to app_bundle::create_app_bundle() instead of duplicating app bundle creation with a different bundle ID - macos.rs autostart now uses Login Items (via app_bundle) instead of launchd, which does not receive Microphone permissions - launchd.rs install() warns that it lacks mic permissions on macOS and recommends app-bundle instead - run_setup() "Next steps" are now platform-aware: macOS shows app-bundle/macos wizard instructions, Linux shows compositor/systemd - Default hotkey suggestion in wizard changed from rightalt to fn - Removed install_launchd_with_app_bundle() and duplicate constants --- src/setup/app_bundle.rs | 2 +- src/setup/launchd.rs | 12 ++ src/setup/macos.rs | 285 ++++++---------------------------------- src/setup/mod.rs | 38 ++++-- 4 files changed, 81 insertions(+), 256 deletions(-) diff --git a/src/setup/app_bundle.rs b/src/setup/app_bundle.rs index 7ad127ea..21a27ba6 100644 --- a/src/setup/app_bundle.rs +++ b/src/setup/app_bundle.rs @@ -13,7 +13,7 @@ use std::process::Command; use super::{get_voxtype_path, print_failure, print_info, print_success, print_warning}; const APP_NAME: &str = "Voxtype.app"; -const BUNDLE_ID: &str = "io.voxtype.daemon"; +pub const BUNDLE_ID: &str = "io.voxtype.daemon"; /// Get the path to the app bundle pub fn app_bundle_path() -> PathBuf { diff --git a/src/setup/launchd.rs b/src/setup/launchd.rs index 1da87492..37cdbbab 100644 --- a/src/setup/launchd.rs +++ b/src/setup/launchd.rs @@ -86,6 +86,18 @@ pub async fn install() -> anyhow::Result<()> { anyhow::bail!("Not on macOS"); } + // Warn about limitations on macOS + #[cfg(target_os = "macos")] + { + print_warning("LaunchAgent services do not receive Microphone permissions on macOS."); + print_warning("Transcription will fail (Whisper outputs silence as 'Thank you')."); + println!(); + print_info("Recommended: use 'voxtype setup app-bundle' instead."); + print_info("The app bundle approach uses Login Items and properly receives"); + print_info("Accessibility, Input Monitoring, and Microphone permissions."); + println!(); + } + // Ensure LaunchAgents directory exists let launch_dir = launch_agents_dir().ok_or_else(|| anyhow::anyhow!("Could not determine LaunchAgents directory"))?; fs::create_dir_all(&launch_dir)?; diff --git a/src/setup/macos.rs b/src/setup/macos.rs index cb5f9919..08aa7a56 100644 --- a/src/setup/macos.rs +++ b/src/setup/macos.rs @@ -1,121 +1,39 @@ //! macOS interactive setup wizard //! //! Provides a guided setup experience for macOS users, covering: -//! - App bundle creation and code signing +//! - App bundle creation and code signing (via app_bundle module) //! - Microphone permission (required for audio capture) //! - Accessibility permission (required for text injection) //! - Notification permission (optional) //! - Hotkey configuration (native rdev or Hammerspoon) -//! - LaunchAgent auto-start +//! - Login Items auto-start (via app_bundle module) //! - Model download use super::{print_failure, print_info, print_success, print_warning}; use std::io::{self, Write}; -use std::path::PathBuf; -const APP_BUNDLE_PATH: &str = "/Applications/Voxtype.app"; -const BUNDLE_IDENTIFIER: &str = "io.voxtype"; - -/// Check if the app bundle exists and is properly signed +/// Check if the app bundle exists and is properly set up fn check_app_bundle() -> bool { - let app_path = PathBuf::from(APP_BUNDLE_PATH); + let app_path = super::app_bundle::app_bundle_path(); let binary_path = app_path.join("Contents/MacOS/voxtype"); let info_plist = app_path.join("Contents/Info.plist"); app_path.exists() && binary_path.exists() && info_plist.exists() } -/// Create the app bundle with proper Info.plist for permissions -async fn create_app_bundle() -> anyhow::Result<()> { - let app_path = PathBuf::from(APP_BUNDLE_PATH); - let contents_path = app_path.join("Contents"); - let macos_path = contents_path.join("MacOS"); - let resources_path = contents_path.join("Resources"); - - // Create directory structure - std::fs::create_dir_all(&macos_path)?; - std::fs::create_dir_all(&resources_path)?; - - // Get current binary path - let current_exe = std::env::current_exe()?; - let binary_dest = macos_path.join("voxtype"); - - // Copy binary to app bundle - std::fs::copy(¤t_exe, &binary_dest)?; - - // Get version from Cargo - let version = env!("CARGO_PKG_VERSION"); - - // Create Info.plist with all required permission descriptions - let info_plist = format!(r#" - - - - CFBundleExecutable - voxtype - CFBundleIdentifier - {} - CFBundleName - Voxtype - CFBundleDisplayName - Voxtype - CFBundlePackageType - APPL - CFBundleShortVersionString - {} - CFBundleVersion - {} - LSMinimumSystemVersion - 11.0 - LSUIElement - - NSHighResolutionCapable - - NSMicrophoneUsageDescription - Voxtype needs microphone access to capture your voice for speech-to-text transcription. - NSAppleEventsUsageDescription - Voxtype needs to send keystrokes to type transcribed text into applications. - - -"#, BUNDLE_IDENTIFIER, version, version); - - std::fs::write(contents_path.join("Info.plist"), info_plist)?; - - // Copy icon if available - if let Some(data_dir) = directories::BaseDirs::new().map(|d| d.data_dir().join("voxtype")) { - let icon_src = data_dir.join("icon.png"); - if icon_src.exists() { - let _ = std::fs::copy(&icon_src, resources_path.join("icon.png")); - } - } - - // Sign the app bundle - let sign_result = tokio::process::Command::new("codesign") - .args(["--force", "--deep", "--sign", "-", APP_BUNDLE_PATH]) - .output() - .await; - - match sign_result { - Ok(output) if output.status.success() => Ok(()), - Ok(output) => { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Code signing failed: {}", stderr) - } - Err(e) => anyhow::bail!("Failed to run codesign: {}", e), - } -} - /// Reset TCC permissions for Voxtype (forces re-prompt) async fn reset_permissions() -> bool { + let bundle_id = super::app_bundle::BUNDLE_ID; + let mic_reset = tokio::process::Command::new("tccutil") - .args(["reset", "Microphone", BUNDLE_IDENTIFIER]) + .args(["reset", "Microphone", bundle_id]) .output() .await .map(|o| o.status.success()) .unwrap_or(false); let acc_reset = tokio::process::Command::new("tccutil") - .args(["reset", "Accessibility", BUNDLE_IDENTIFIER]) + .args(["reset", "Accessibility", bundle_id]) .output() .await .map(|o| o.status.success()) @@ -124,24 +42,8 @@ async fn reset_permissions() -> bool { mic_reset || acc_reset } -/// Check if microphone permission is granted -/// This is tricky - we can't directly check, but we can try to access an audio device -async fn check_microphone_permission() -> bool { - // Use a simple AppleScript to check if we can access audio input - // This isn't perfect but gives a reasonable indication - let output = tokio::process::Command::new("osascript") - .args(["-e", "do shell script \"echo test\""]) - .output() - .await; - - // For now, we'll assume permission is needed and guide the user - // The real check happens when the daemon tries to capture audio - output.map(|o| o.status.success()).unwrap_or(false) -} - /// Check if Accessibility permission is granted using AXIsProcessTrusted equivalent async fn check_accessibility_permission() -> bool { - // Try to use osascript to control System Events - this requires Accessibility let output = tokio::process::Command::new("osascript") .args(["-e", "tell application \"System Events\" to return name of first process"]) .output() @@ -165,36 +67,6 @@ async fn open_privacy_settings(pane: &str) -> bool { .unwrap_or(false) } -/// Check if Voxtype is in the Accessibility list (even if disabled) -async fn is_in_accessibility_list() -> bool { - // Check the TCC database - this is a heuristic - let output = tokio::process::Command::new("sqlite3") - .args([ - "/Library/Application Support/com.apple.TCC/TCC.db", - &format!("SELECT client FROM access WHERE client='{}' AND service='kTCCServiceAccessibility'", BUNDLE_IDENTIFIER), - ]) - .output() - .await; - - // If we can't query (permission denied), check user database - if output.is_err() || !output.as_ref().unwrap().status.success() { - if let Some(home) = directories::BaseDirs::new().map(|d| d.home_dir().to_path_buf()) { - let user_db = home.join("Library/Application Support/com.apple.TCC/TCC.db"); - let output = tokio::process::Command::new("sqlite3") - .args([ - user_db.to_str().unwrap_or(""), - &format!("SELECT client FROM access WHERE client='{}' AND service='kTCCServiceAccessibility'", BUNDLE_IDENTIFIER), - ]) - .output() - .await; - - return output.map(|o| !o.stdout.is_empty()).unwrap_or(false); - } - } - - output.map(|o| !o.stdout.is_empty()).unwrap_or(false) -} - /// Check if Hammerspoon is installed async fn check_hammerspoon() -> bool { std::path::Path::new("/Applications/Hammerspoon.app").exists() @@ -315,7 +187,9 @@ fn install_default_icon_file() -> anyhow::Result<()> { /// Get the app bundle binary path pub fn get_app_bundle_path() -> String { - format!("{}/Contents/MacOS/voxtype", APP_BUNDLE_PATH) + super::app_bundle::app_binary_path() + .to_string_lossy() + .to_string() } /// Run the macOS setup wizard @@ -333,7 +207,7 @@ pub async fn run() -> anyhow::Result<()> { let recreate = prompt_yn("Recreate app bundle? (recommended after updates)", true); if recreate { println!(" Creating app bundle..."); - match create_app_bundle().await { + match super::app_bundle::create_app_bundle() { Ok(_) => print_success("App bundle created and signed"), Err(e) => { print_failure(&format!("Failed to create app bundle: {}", e)); @@ -352,7 +226,7 @@ pub async fn run() -> anyhow::Result<()> { let create = prompt_yn("Create app bundle?", true); if create { println!(" Creating app bundle..."); - match create_app_bundle().await { + match super::app_bundle::create_app_bundle() { Ok(_) => print_success("App bundle created and signed"), Err(e) => { print_failure(&format!("Failed to create app bundle: {}", e)); @@ -374,21 +248,20 @@ pub async fn run() -> anyhow::Result<()> { let setup_mic = prompt_yn("Set up microphone permission now?", true); if setup_mic { - // Reset permissions to ensure a fresh prompt let _ = reset_permissions().await; - // Open System Settings to Microphone print_info("Opening System Settings > Privacy & Security > Microphone..."); open_privacy_settings("Microphone").await; tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - // Launch the app to trigger the permission prompt - print_info("Launching Voxtype to trigger permission prompt..."); - let app_binary = get_app_bundle_path(); - let _ = tokio::process::Command::new(&app_binary) - .arg("daemon") - .spawn(); + // Launch the app bundle to trigger the permission prompt + print_info("Launching Voxtype.app to trigger permission prompt..."); + let app_path = super::app_bundle::app_bundle_path(); + let _ = tokio::process::Command::new("open") + .arg(app_path.as_os_str()) + .output() + .await; tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; @@ -403,7 +276,7 @@ pub async fn run() -> anyhow::Result<()> { // Kill the test daemon let _ = tokio::process::Command::new("pkill") - .args(["-f", "voxtype"]) + .args(["-9", "-f", "Voxtype.app"]) .output() .await; @@ -461,7 +334,6 @@ pub async fn run() -> anyhow::Result<()> { wait_for_enter("Press Enter when Accessibility permission is granted..."); - // Verify permission let has_acc_now = check_accessibility_permission().await; if has_acc_now { print_success("Accessibility permission granted"); @@ -524,7 +396,7 @@ pub async fn run() -> anyhow::Result<()> { false }; - let hotkey = prompt("\nHotkey to use", "rightalt"); + let hotkey = prompt("\nHotkey to use", "fn"); let toggle_mode = prompt_yn("Use toggle mode? (press to start/stop instead of hold)", false); if use_hammerspoon { @@ -539,22 +411,21 @@ pub async fn run() -> anyhow::Result<()> { print_info(&format!("Mode: {}", if toggle_mode { "toggle" } else { "push-to-talk" })); } - // Step 7: Auto-start + // Step 7: Auto-start (Login Items) section("Step 7: Auto-start Configuration"); - println!("Voxtype can start automatically when you log in.\n"); + println!("Voxtype can start automatically when you log in via Login Items.\n"); - let setup_autostart = prompt_yn("Set up auto-start (LaunchAgent)?", true); + let setup_autostart = prompt_yn("Add to Login Items?", true); if setup_autostart { - println!(); - println!("Installing LaunchAgent..."); - - // First, update launchd to use the app bundle path - if let Err(e) = install_launchd_with_app_bundle().await { - print_warning(&format!("Could not install LaunchAgent: {}", e)); - } else { - print_success("LaunchAgent installed"); + match super::app_bundle::add_to_login_items() { + Ok(true) => print_success("Added to Login Items"), + Ok(false) => { + print_warning("Could not add to Login Items automatically"); + print_info("Add manually: System Settings > General > Login Items"); + } + Err(e) => print_warning(&format!("Could not add to Login Items: {}", e)), } } @@ -753,25 +624,17 @@ pub async fn run() -> anyhow::Result<()> { println!(" Hotkey: {} ({})", hotkey, if toggle_mode { "toggle" } else { "push-to-talk" }); println!(" Engine: {}", engine_name); println!(" Model: {}", model); - println!(" Auto-start: {}", if setup_autostart { "enabled" } else { "disabled" }); + println!(" Auto-start: {}", if setup_autostart { "Login Items" } else { "disabled" }); println!("\n\x1b[1mStarting Voxtype...\x1b[0m\n"); - // Start the daemon - if setup_autostart { - let _ = tokio::process::Command::new("launchctl") - .args(["load", &format!("{}/Library/LaunchAgents/io.voxtype.daemon.plist", - dirs::home_dir().map(|h| h.to_string_lossy().to_string()).unwrap_or_default())]) - .output() - .await; - print_success("Daemon started via LaunchAgent"); - } else { - let app_binary = get_app_bundle_path(); - let _ = tokio::process::Command::new(&app_binary) - .arg("daemon") - .spawn(); - print_success("Daemon started"); - } + // Start via open (preserves app bundle identity for permissions) + let app_path = super::app_bundle::app_bundle_path(); + let _ = tokio::process::Command::new("open") + .arg(app_path.as_os_str()) + .output() + .await; + print_success("Voxtype.app started"); println!(); println!("Press {} to start recording!", hotkey); @@ -779,74 +642,8 @@ pub async fn run() -> anyhow::Result<()> { println!("Useful commands:"); println!(" voxtype status - Check daemon status"); println!(" voxtype status --follow - Watch status in real-time"); + println!(" voxtype setup app-bundle --status - Check app bundle status"); println!(" voxtype record toggle - Toggle recording from CLI"); Ok(()) } - -/// Install LaunchAgent configured to use the app bundle -async fn install_launchd_with_app_bundle() -> anyhow::Result<()> { - let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; - let launch_agents_dir = home.join("Library/LaunchAgents"); - let logs_dir = home.join("Library/Logs/voxtype"); - - std::fs::create_dir_all(&launch_agents_dir)?; - std::fs::create_dir_all(&logs_dir)?; - - let app_binary = get_app_bundle_path(); - - let plist_content = format!(r#" - - - - Label - io.voxtype.daemon - - ProgramArguments - - {} - daemon - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - {}/stdout.log - - StandardErrorPath - {}/stderr.log - - EnvironmentVariables - - PATH - /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin - - - ProcessType - Interactive - - Nice - -10 - - -"#, app_binary, logs_dir.display(), logs_dir.display()); - - let plist_path = launch_agents_dir.join("io.voxtype.daemon.plist"); - - // Unload existing if present - let _ = tokio::process::Command::new("launchctl") - .args(["unload", plist_path.to_str().unwrap_or("")]) - .output() - .await; - - std::fs::write(&plist_path, plist_content)?; - - print_success(&format!("Created: {}", plist_path.display())); - print_success(&format!("Logs: {}", logs_dir.display())); - - Ok(()) -} diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 5ec5d2c2..85141aa5 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -677,17 +677,33 @@ pub async fn run_setup( if !quiet && !no_post_install { println!(); println!("Next steps:"); - println!(" 1. Set up a compositor keybinding to trigger recording:"); - println!( - " Example for Hyprland: bind = , XF86AudioRecord, exec, voxtype record-toggle\n" - ); - println!(" 2. Start the daemon: voxtype daemon\n"); - println!("Optional:"); - println!(" voxtype setup check - Verify system configuration"); - println!(" voxtype setup model - Download/switch whisper models"); - println!(" voxtype setup systemd - Install as systemd service"); - println!(" voxtype setup waybar - Get Waybar integration config"); - println!(" voxtype setup compositor - Fix modifier key issues (Hyprland/Sway/River)"); + + #[cfg(target_os = "macos")] + { + println!(" 1. Install as app bundle (recommended):"); + println!(" voxtype setup app-bundle\n"); + println!(" 2. Or run the interactive setup wizard:"); + println!(" voxtype setup macos\n"); + println!("Optional:"); + println!(" voxtype setup check - Verify system configuration"); + println!(" voxtype setup model - Download/switch whisper models"); + println!(" voxtype setup app-bundle --status - Check installation status"); + } + + #[cfg(not(target_os = "macos"))] + { + println!(" 1. Set up a compositor keybinding to trigger recording:"); + println!( + " Example for Hyprland: bind = , XF86AudioRecord, exec, voxtype record-toggle\n" + ); + println!(" 2. Start the daemon: voxtype daemon\n"); + println!("Optional:"); + println!(" voxtype setup check - Verify system configuration"); + println!(" voxtype setup model - Download/switch whisper models"); + println!(" voxtype setup systemd - Install as systemd service"); + println!(" voxtype setup waybar - Get Waybar integration config"); + println!(" voxtype setup compositor - Fix modifier key issues (Hyprland/Sway/River)"); + } } Ok(()) From d047e666357f0527b53be965b4ebbb2d82d4fbc8 Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 25 Feb 2026 21:51:00 +0100 Subject: [PATCH 32/33] Fix macOS app bundle: self-copy corruption, code signing, and permission handling - Fix self-copy corruption: skip copy when source == dest, use temp file + atomic rename otherwise to prevent truncation when updating from within the app bundle itself - Fix code signing: sign voxtype-bin individually before signing the bundle so the Mach-O gets proper code page hashes (was SIGKILL'd) - Replace wrapper script with direct binary launch: set voxtype-bin as CFBundleExecutable and add hidden AppLaunch command that starts daemon in background + menubar in foreground. The wrapper script's exec() broke macOS Control Center's XPC scene registration, preventing the menubar icon from appearing - Auto-launch after install: run open Voxtype.app at end of setup - Implement real Accessibility permission check using CGEventTapCreate (not AXIsProcessTrusted which caches per-process) with auto-restart: daemon polls every 2s and restarts itself when permission is granted - Prompt for Accessibility via AXIsProcessTrustedWithOptions on startup - Reset TCC entries only when the binary actually changed to avoid wiping permissions on self-copy reinstalls --- src/cli.rs | 5 +++ src/hotkey_macos.rs | 85 +++++++++++++++++++++++++++++++++--- src/main.rs | 53 ++++++++++++++++++++++- src/setup/app_bundle.rs | 95 ++++++++++++++++++++--------------------- 4 files changed, 183 insertions(+), 55 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 30392a06..2ebda9f4 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -282,6 +282,11 @@ pub enum Commands { #[cfg(target_os = "macos")] Menubar, + /// Launch daemon + menubar (used by Voxtype.app bundle) + #[cfg(target_os = "macos")] + #[command(hide = true)] + AppLaunch, + /// Transcribe an audio file (WAV, 16kHz, mono) Transcribe { /// Path to audio file diff --git a/src/hotkey_macos.rs b/src/hotkey_macos.rs index 90a8f880..5d4225c4 100644 --- a/src/hotkey_macos.rs +++ b/src/hotkey_macos.rs @@ -62,12 +62,56 @@ impl RdevHotkeyListener { impl HotkeyListener for RdevHotkeyListener { fn start(&mut self) -> Result> { + // Check/request Accessibility permission before starting the listener. + // This triggers the macOS system dialog if permission hasn't been granted. + if !check_accessibility_permission() { + tracing::warn!( + "Accessibility permission not granted. \ + macOS should have shown a permission dialog. \ + Grant access in: System Settings > Privacy & Security > Accessibility" + ); + } + let (tx, rx) = mpsc::channel(32); let target_key = self.target_key; let cancel_key = self.cancel_key; let running = self.running.clone(); running.store(true, Ordering::SeqCst); + // If Accessibility permission isn't granted, rdev::listen() creates a dead + // event tap that never fires. The only fix is to restart the process after + // permission is granted. Spawn a watcher that re-execs when permission appears. + if !is_accessibility_granted() { + let running_watcher = running.clone(); + std::thread::spawn(move || { + loop { + if !running_watcher.load(Ordering::SeqCst) { + return; + } + std::thread::sleep(Duration::from_secs(2)); + if is_accessibility_granted() { + tracing::info!( + "Accessibility permission granted, restarting daemon to activate hotkey..." + ); + // Remove lock file so the new process can acquire it + let lock_path = crate::config::Config::runtime_dir().join("voxtype.lock"); + let _ = std::fs::remove_file(&lock_path); + // Spawn a new daemon and exit. The dead CGEvent tap in this + // process can't be revived; a fresh process is needed. + let exe = std::env::current_exe().expect("current_exe"); + let args: Vec = std::env::args().skip(1).collect(); + match std::process::Command::new(&exe).args(&args).spawn() { + Ok(_) => std::process::exit(0), + Err(e) => { + tracing::error!("Failed to restart: {}", e); + return; + } + } + } + } + }); + } + let thread_handle = std::thread::spawn(move || { let tx_clone = tx.clone(); let running_clone = running.clone(); @@ -225,13 +269,42 @@ pub fn create_listener(config: &HotkeyConfig) -> Result> Ok(Box::new(RdevHotkeyListener::new(config)?)) } -/// Check if rdev hotkey capture is likely to work -/// (Accessibility permission is required) +/// Check if Accessibility permission is granted by trying to create an event tap. +/// Unlike AXIsProcessTrusted(), this is not cached and reflects the current state. +fn is_accessibility_granted() -> bool { + use core_graphics::event::{CGEventTap, CGEventTapLocation, CGEventTapPlacement, CGEventTapOptions, CGEventType}; + + let tap = CGEventTap::new( + CGEventTapLocation::Session, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + vec![CGEventType::KeyDown], + |_, _, _| None, + ); + tap.is_ok() +} + +/// Check if Accessibility permission is granted, prompting the user if not. +/// +/// Calls AXIsProcessTrustedWithOptions with kAXTrustedCheckOptionPrompt=true, +/// which makes macOS show the "App wants to control this computer" dialog +/// if permission hasn't been granted yet. pub fn check_accessibility_permission() -> bool { - // On macOS, we can try to create an event tap to check permissions - // rdev will fail at runtime if permissions aren't granted - // For now, return true and let it fail gracefully with a helpful message - true + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrustedWithOptions(options: core_foundation::base::CFTypeRef) -> bool; + } + + use core_foundation::base::TCFType; + use core_foundation::boolean::CFBoolean; + use core_foundation::dictionary::CFDictionary; + use core_foundation::string::CFString; + + let key = CFString::new("AXTrustedCheckOptionPrompt"); + let value = CFBoolean::true_value(); + let options = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); + + unsafe { AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef() as _) } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index b4e6bb13..40608ed3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -332,8 +332,23 @@ async fn main() -> anyhow::Result<()> { config.vad.min_speech_duration_ms = min_speech; } + // On macOS, detect if launched as app bundle executable (no subcommand, binary inside .app) + #[cfg(target_os = "macos")] + let default_command = if cli.command.is_none() { + std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(|s| s.contains(".app/Contents/MacOS/"))) + .unwrap_or(false) + .then_some(Commands::AppLaunch) + .unwrap_or(Commands::Daemon) + } else { + Commands::Daemon // unused, cli.command is Some + }; + #[cfg(not(target_os = "macos"))] + let default_command = Commands::Daemon; + // Run the appropriate command - match cli.command.unwrap_or(Commands::Daemon) { + match cli.command.unwrap_or(default_command) { Commands::Daemon => { let mut daemon = daemon::Daemon::new(config, config_path); daemon.run().await?; @@ -346,6 +361,42 @@ async fn main() -> anyhow::Result<()> { menubar::run(state_file); // Note: menubar::run() never returns (runs macOS event loop) } + #[cfg(target_os = "macos")] + Commands::AppLaunch => { + // Launched by Voxtype.app: start daemon in background, run menubar in foreground. + // The binary must be the CFBundleExecutable (not exec'd from a wrapper script) + // so macOS Control Center can register the status bar scene correctly. + let logs_dir = dirs::home_dir() + .map(|h| h.join("Library/Logs/voxtype")) + .unwrap_or_else(|| std::path::PathBuf::from("/tmp/voxtype")); + let _ = std::fs::create_dir_all(&logs_dir); + + // Kill any existing instances + let _ = std::process::Command::new("pkill") + .args(["-9", "-f", "voxtype-bin daemon"]) + .status(); + let _ = std::process::Command::new("pkill") + .args(["-9", "-f", "voxtype-bin menubar"]) + .status(); + let _ = std::fs::remove_file("/tmp/voxtype/voxtype.lock"); + let _ = std::fs::remove_file("/tmp/voxtype/menubar.lock"); + + // Start daemon as a child process with logging + let exe = std::env::current_exe()?; + let stdout = std::fs::File::create(logs_dir.join("stdout.log"))?; + let stderr = std::fs::File::create(logs_dir.join("stderr.log"))?; + let _daemon = std::process::Command::new(&exe) + .arg("daemon") + .stdout(stdout) + .stderr(stderr) + .spawn()?; + + // Run menubar in this process (keeps the app alive with menu bar icon) + let state_file = config + .resolve_state_file() + .ok_or_else(|| anyhow::anyhow!("state_file not configured"))?; + menubar::run(state_file); + } Commands::Transcribe { file, engine } => { if let Some(engine_name) = engine { diff --git a/src/setup/app_bundle.rs b/src/setup/app_bundle.rs index b6779add..b0efacf3 100644 --- a/src/setup/app_bundle.rs +++ b/src/setup/app_bundle.rs @@ -25,7 +25,7 @@ pub fn app_binary_path() -> PathBuf { app_bundle_path() .join("Contents") .join("MacOS") - .join("voxtype") + .join("voxtype-bin") } /// Get the path to the logs directory @@ -41,7 +41,7 @@ fn generate_info_plist(version: &str) -> String { CFBundleExecutable - voxtype + voxtype-bin CFBundleIdentifier {bundle_id} CFBundleName @@ -70,34 +70,6 @@ fn generate_info_plist(version: &str) -> String { ) } -/// Generate wrapper script that runs the daemon and menubar -fn generate_wrapper_script() -> String { - let logs = logs_dir().unwrap_or_else(|| PathBuf::from("/tmp/voxtype")); - format!( - r#"#!/bin/bash -# Voxtype app wrapper - starts daemon and menu bar - -# Kill any existing instances -pkill -9 -f "voxtype daemon" 2>/dev/null -pkill -9 -f "voxtype menubar" 2>/dev/null -rm -f /tmp/voxtype/voxtype.lock - -# Create logs directory -mkdir -p "{logs}" - -# Get the directory where this script is located -DIR="$(cd "$(dirname "$0")" && pwd)" - -# Start daemon in background with logging -"$DIR/voxtype-bin" daemon >> "{logs}/stdout.log" 2>> "{logs}/stderr.log" & - -# Start menubar (foreground keeps app alive and shows menu bar icon) -exec "$DIR/voxtype-bin" menubar -"#, - logs = logs.display() - ) -} - /// Create the app bundle pub fn create_app_bundle() -> anyhow::Result<()> { let app_path = app_bundle_path(); @@ -116,34 +88,50 @@ pub fn create_app_bundle() -> anyhow::Result<()> { generate_info_plist(version), )?; - // Copy the current voxtype binary + // Copy the current voxtype binary (handle self-copy case) let source_binary = get_voxtype_path(); let dest_binary = macos_path.join("voxtype-bin"); - fs::copy(&source_binary, &dest_binary)?; + let source_canon = fs::canonicalize(&source_binary).unwrap_or_else(|_| PathBuf::from(&source_binary)); + let dest_canon = fs::canonicalize(&dest_binary).unwrap_or_else(|_| dest_binary.clone()); + + let binary_replaced = source_canon != dest_canon; + if binary_replaced { + // Copy via temp file for atomicity (prevents corruption if interrupted) + let temp_binary = macos_path.join("voxtype-bin.tmp"); + fs::copy(&source_binary, &temp_binary)?; + fs::rename(&temp_binary, &dest_binary)?; + } // Make binary executable let mut perms = fs::metadata(&dest_binary)?.permissions(); perms.set_mode(0o755); fs::set_permissions(&dest_binary, perms)?; - // Create wrapper script as main executable + // Remove legacy wrapper script if present (replaced by direct binary launch) let wrapper_path = macos_path.join("voxtype"); - fs::write(&wrapper_path, generate_wrapper_script())?; - let mut perms = fs::metadata(&wrapper_path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(&wrapper_path, perms)?; + let _ = fs::remove_file(&wrapper_path); + + // Sign the Mach-O binary individually first so it gets proper code page hashes, + // then sign the whole bundle (--deep alone doesn't always hash inner binaries correctly) + let _ = Command::new("codesign") + .args(["--force", "--sign", "-", dest_binary.to_str().unwrap()]) + .output(); - // Code sign the app bundle (ad-hoc) let _ = Command::new("codesign") - .args([ - "--force", - "--deep", - "--sign", - "-", - app_path.to_str().unwrap(), - ]) + .args(["--force", "--deep", "--sign", "-", app_path.to_str().unwrap()]) .output(); + // Reset TCC entries only when the binary changed, so macOS re-prompts for the + // new code signature. Skip on self-copy to preserve existing permissions. + if binary_replaced { + let _ = Command::new("tccutil") + .args(["reset", "Accessibility", BUNDLE_ID]) + .output(); + let _ = Command::new("tccutil") + .args(["reset", "ListenEvent", BUNDLE_ID]) + .output(); + } + Ok(()) } @@ -239,6 +227,20 @@ pub async fn install() -> anyhow::Result<()> { print_info("Add manually: System Settings > General > Login Items"); } + // Launch the app + let launched = Command::new("open") + .arg(app_bundle_path().as_os_str()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + + if launched { + print_success("Launched Voxtype.app"); + } else { + print_warning("Could not launch automatically"); + print_info("Start manually: open /Applications/Voxtype.app"); + } + println!("\n---"); println!("\x1b[32m✓ Installation complete!\x1b[0m"); println!(); @@ -253,9 +255,6 @@ pub async fn install() -> anyhow::Result<()> { println!(" 3. System Settings > Privacy & Security > \x1b[1mMicrophone\x1b[0m"); println!(" Voxtype should appear after first use - enable it"); println!(); - println!("To start now:"); - println!(" open /Applications/Voxtype.app"); - println!(); println!("Voxtype will start automatically on login."); Ok(()) From b8b83a3e26f70d52e0dbc80c0d35d3a1283eaabd Mon Sep 17 00:00:00 2001 From: Christopher Albert Date: Wed, 25 Feb 2026 21:57:29 +0100 Subject: [PATCH 33/33] Replace menubar polling with kqueue file watching for instant icon updates State file changes are now picked up via notify (kqueue on macOS) instead of polling every 500ms, eliminating the visible delay between hotkey press and icon update. --- src/menubar.rs | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/menubar.rs b/src/menubar.rs index 118d6f09..cce8194b 100644 --- a/src/menubar.rs +++ b/src/menubar.rs @@ -4,6 +4,7 @@ //! for controlling recording and configuring settings. use crate::config::{ActivationMode, Config, OutputMode, TranscriptionEngine}; +use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher}; use pidlock::Pidlock; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -536,10 +537,31 @@ pub fn run(state_file: PathBuf) -> ! { // Track state let mut last_state = initial_state; - let mut last_update = Instant::now(); - let update_interval = Duration::from_millis(500); let running = Arc::new(AtomicBool::new(true)); + // Watch state file for changes via kqueue (instant notification, no polling) + let state_changed = Arc::new(AtomicBool::new(false)); + let state_changed_writer = state_changed.clone(); + let watch_path = state_file.clone(); + let _watcher = { + let mut watcher: RecommendedWatcher = + notify::recommended_watcher(move |res: notify::Result| { + if let Ok(event) = res { + if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) { + state_changed_writer.store(true, Ordering::SeqCst); + } + } + }) + .expect("Failed to create file watcher"); + // Watch the parent directory since the state file may be recreated + if let Some(parent) = watch_path.parent() { + watcher + .watch(parent, RecursiveMode::NonRecursive) + .unwrap_or_else(|e| eprintln!("Warning: could not watch state dir: {}", e)); + } + watcher // keep alive + }; + // Set up menu event receiver let menu_channel = MenuEvent::receiver(); @@ -547,7 +569,7 @@ pub fn run(state_file: PathBuf) -> ! { let event_loop = EventLoopBuilder::new().build(); event_loop.run(move |_event, _, control_flow| { - // Set to poll mode so we can check state periodically + // Wake every 100ms to check menu events; state updates arrive via kqueue flag *control_flow = ControlFlow::WaitUntil(Instant::now() + Duration::from_millis(100)); // Check for menu events (non-blocking) @@ -677,18 +699,15 @@ pub fn run(state_file: PathBuf) -> ! { } } - // Update state periodically - if last_update.elapsed() >= update_interval { + // Update state when file watcher signals a change + if state_changed.swap(false, Ordering::SeqCst) { let new_state = read_state_from_file(&state_file); if new_state != last_state { - // Update icon and status text let _ = tray.set_title(Some(new_state.icon())); let _ = status_item.set_text(new_state.status_text()); last_state = new_state; } - - last_update = Instant::now(); } if !running.load(Ordering::SeqCst) {