Skip to content

Add macOS support#263

Open
sebassdc wants to merge 1 commit intopeteonrails:mainfrom
sebassdc:feat/macos-support
Open

Add macOS support#263
sebassdc wants to merge 1 commit intopeteonrails:mainfrom
sebassdc:feat/macos-support

Conversation

@sebassdc
Copy link

@sebassdc sebassdc commented Mar 15, 2026

Summary

  • Gate Linux-only dependencies (evdev, inotify) behind target_os = "linux" so voxtype builds on macOS
  • Make clipboard, paste, and notification systems cross-platform using pbcopy/pbpaste and osascript on macOS
  • Fix process existence check to use kill(pid, 0) instead of /proc on non-Linux platforms
  • Use std::env::temp_dir() for portable runtime directory fallback

On macOS, users set [hotkey] enabled = false and use external tools (skhd, Hammerspoon, macOS Shortcuts) to bind voxtype record toggle to a keyboard shortcut. Audio capture via cpal/CoreAudio and transcription (whisper-rs local or remote API) work without changes.

Files changed (13 files, +265 -108)

Area Files What changed
Build Cargo.toml evdev/inotify moved to [target.'cfg(target_os = "linux")'.dependencies]
CPU src/cpu.rs .init_array ELF constructor gated behind cfg(target_os = "linux")
Errors src/error.rs From<evdev::Error> gated behind cfg(target_os = "linux")
Hotkey src/hotkey/mod.rs evdev_listener module gated; non-Linux create_listener returns helpful error
Config src/config.rs runtime_dir() uses std::env::temp_dir() instead of hardcoded /tmp
Output src/output/mod.rs New cross-platform send_desktop_notification() (osascript on macOS, notify-send on Linux)
Clipboard src/output/clipboard.rs Uses pbcopy on macOS, wl-copy on Linux
Paste src/output/paste.rs Uses pbcopy/pbpaste + osascript Cmd+V on macOS
Notifications src/output/{dotool,ydotool,xclip}.rs Replaced direct notify-send with cross-platform helper
Daemon src/daemon.rs Uses cross-platform notification helper
Main src/main.rs /proc/{pid} check replaced with kill(pid, 0); macOS notification fallback

How it was tested on macOS

Build and unit tests

Built and tested on macOS (Apple Silicon, aarch64-apple-darwin):

cargo build --release   # Succeeds, no new warnings
cargo test              # 511/512 pass (1 pre-existing failure unrelated to this PR)

Daemon setup

Since evdev hotkey detection is Linux-only, macOS requires disabling the built-in hotkey and using an external hotkey tool to send voxtype record toggle commands to the daemon.

Config (~/.config/voxtype/config.toml):

[hotkey]
enabled = false
mode = "toggle"

[whisper]
mode = "remote"
remote_endpoint = "https://api.openai.com"
remote_model = "whisper-1"
# API key set via env var: VOXTYPE_WHISPER_API_KEY

[output]
mode = "paste"

Key points:

  • hotkey.enabled = false disables the evdev listener
  • whisper.mode = "remote" with OpenAI endpoint for transcription (local whisper-rs also works on macOS)
  • output.mode = "paste" uses pbcopy + osascript to simulate Cmd+V, pasting transcribed text at the cursor

Starting the daemon:

export VOXTYPE_WHISPER_API_KEY="sk-..."
voxtype daemon &

The daemon correctly uses macOS directories (~/Library/Application Support/voxtype/ for config/models, $TMPDIR/voxtype/ for runtime state).

Hotkey binding with skhd

skhd was used as the external hotkey daemon (any tool that can run shell commands on a keybind works):

brew install koekeishiya/formulae/skhd

~/.skhdrc:

cmd + ctrl - x : /path/to/voxtype record toggle
skhd --start-service

Requires Accessibility permissions in System Settings > Privacy & Security > Accessibility.

Simple-bar (Übersicht) status widget

A custom user widget was added to simple-bar to show daemon state in the macOS menu bar. This uses the same state file mechanism as the Waybar integration on Linux.

Status script (~/.local/bin/voxtype-status-widget):

#!/bin/sh
STATE_FILE="${TMPDIR:-/tmp}/voxtype/state"

if ! [ -f "$STATE_FILE" ]; then
    echo "🎙 OFF"
    exit 0
fi

STATE=$(cat "$STATE_FILE" 2>/dev/null | head -1 | tr -d '[:space:]')

case "$STATE" in
    recording)    echo "🔴 REC" ;;
    transcribing) echo "⏳ ..." ;;
    idle)         echo "🎙 ON"  ;;
    *)            echo "🎙 OFF" ;;
esac

Widget config in ~/.simplebarrc:

{
  "userWidgets": {
    "userWidgetsList": {
      "1": {
        "title": "Voxtype",
        "icon": "Mic",
        "backgroundColor": "--cyan",
        "output": "~/.local/bin/voxtype-status-widget",
        "onClickAction": "voxtype record toggle",
        "refreshFrequency": 500,
        "active": true,
        "hideWhenNoOutput": false
      }
    }
  }
}

The widget updates every 500ms and shows recording state visually. Clicking it toggles recording.

End-to-end recording cycle

Verified the full flow:

  1. Press Cmd+Ctrl+X (via skhd) to start recording - widget shows 🔴 REC
  2. Speak into microphone (tested with USB audio interface via CoreAudio/cpal)
  3. Press Cmd+Ctrl+X again to stop - widget shows ⏳ ...
  4. Audio is sent to OpenAI Whisper API, transcription returns in ~2s
  5. Text is copied to clipboard via pbcopy, then pasted at cursor via osascript Cmd+V simulation
  6. macOS notification appears via osascript display notification
  7. Widget returns to 🎙 ON

Example daemon log from a successful cycle:

INFO  Recording started (external trigger)
INFO  Using audio device: <audio device>
INFO  Configured remote transcriber: endpoint=https://api.openai.com, model=whisper-1, timeout=30s
INFO  Recording stopped (3.9s)
INFO  Transcribing 3.3s of audio...
INFO  Remote transcription completed in 2.18s: "..."
INFO  Transcribed: "..."
INFO  Text pasted via clipboard (35 chars)

What was NOT tested

  • Local whisper-rs transcription with Metal GPU acceleration (--features gpu-metal)
  • voxtype setup systemd / voxtype setup compositor (Linux-only, but they compile fine and will just fail gracefully on macOS)
  • Linux regression (existing CI should cover this)

Test plan

  • cargo build --release succeeds on macOS (aarch64-apple-darwin)
  • cargo check succeeds on macOS
  • cargo test passes (511/512, 1 pre-existing failure unrelated to this PR)
  • Daemon starts and runs on macOS with hotkey.enabled = false
  • Recording cycle works via voxtype record start/stop/toggle
  • Remote transcription (OpenAI Whisper API) works
  • Paste output works via pbcopy + osascript Cmd+V
  • Desktop notifications work via osascript
  • State file works for external integrations (simple-bar widget)
  • Signal-based IPC (SIGUSR1/SIGUSR2) works on macOS
  • Verify no regressions on Linux (existing CI should cover this)

🤖 Generated with Claude Code

Gate Linux-only dependencies (evdev, inotify) behind target_os = "linux".
Make output, clipboard, paste, and notification systems cross-platform
using pbcopy/pbpaste and osascript on macOS. Fix process existence check
to use kill(pid, 0) instead of /proc on non-Linux platforms.

Changes:
- Cargo.toml: Move evdev/inotify to Linux-only target deps
- cpu.rs: Gate .init_array ELF section behind cfg(target_os = "linux")
- error.rs: Gate evdev error conversion behind cfg(target_os = "linux")
- hotkey/mod.rs: Gate evdev_listener, return helpful error on macOS
- config.rs: Use std::env::temp_dir() for portable runtime_dir fallback
- output/clipboard.rs: Use pbcopy on macOS, wl-copy on Linux
- output/paste.rs: Use pbcopy/pbpaste + osascript Cmd+V on macOS
- output/mod.rs: Add cross-platform send_desktop_notification helper
- output/dotool.rs, ydotool.rs, xclip.rs: Use notification helper
- daemon.rs: Use cross-platform notification helper
- main.rs: Fix /proc check, add macOS notification fallback

On macOS, users disable built-in hotkey detection and use external tools
(skhd, Hammerspoon) to bind voxtype record toggle to a keyboard shortcut.
Audio capture (cpal/CoreAudio) and transcription (whisper-rs, remote API)
work without changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@sebassdc sebassdc requested a review from peteonrails as a code owner March 15, 2026 19:40
@sebassdc
Copy link
Author

HUGE DISCLAIMER: Im not an experienced rust dev, not a macos dev neither, this was done by my clanker to make it work on my machine, i just thought it could be useful to someone. @peteonrails I saw you have a PR #129,

If this brings no value please close without remorse. I don't want to add friction to this amazing tool! Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant