#!/bin/bash # Wispr Flow's dictation shortcuts, on the Caps Lock key: Push to talk on F20, # Command Mode on Ctrl+F20. Double-tap for hands-free comes from the Push to # talk binding and needs none of its own. # # wispr-keys # # Caps Lock sends F20 on these Macs (dotlib.capslock_f20, see AGENTS.md), and # Flow's settings screen refuses F20 on its own -- "Shortcut must include a # modifier key or a valid mouse button" -- while its key listener accepts it. # So the bindings go into Flow's config.json instead, which is what this is for. # # Run it by hand: on a Mac that has just installed Flow, and again after # re-binding either action in Flow's settings, which overwrites what this sets. # Deliberately not a link/ part, though it looks like one: it has to quit and # restart Flow, and nothing that runs unattended after every sync should be # able to kill a dictation in progress. Nothing calls this on its own. # # How Flow stores shortcuts, read from Flow 1.6.1021's own app.asar rather than # guessed: prefs.user.shortcuts maps a key to an action, where the key is the # macOS key codes string-sorted and joined with "+" (Flow does # e.sort().join("+")). Its key table has f20: 90 and ctrl: 59, so Ctrl+F20 is # "59+90". ptt is Push to talk; lens is Command Mode -- the settings dialog # draws that row from Lens, and the recorder picks Lens when isCommandMode. # prefs.cache.splitKeybinds holds the same bindings as arrays, and is kept in # step here so Flow does not read back a disagreeing pair. set -euo pipefail if [[ "$(uname)" != "Darwin" ]] then >&2 echo "ERROR: macOS only" exit 1 fi config="$HOME/Library/Application Support/Wispr Flow/config.json" if [[ ! -f "$config" ]] then >&2 echo "ERROR: no Flow config at ${config}" >&2 echo "Install Wispr Flow and start it once; it writes that file on first launch." exit 1 fi if ! command -v jq >/dev/null then >&2 echo "ERROR: jq is needed to edit Flow's config and is not on PATH" exit 1 fi # Drop whatever ptt and lens are bound to now, and anything else holding the # keys they are about to take, then bind them. splitKeybinds is absent from a # config Flow has not written a cache into yet, and `map` over null is an # error, hence the `// []`. read -r -d '' filter <<'JQ' || true .prefs.user.shortcuts |= ( with_entries(select( .value != "ptt" and .value != "lens" and .key != "90" and .key != "59+90")) + {"90": "ptt", "59+90": "lens"}) | .prefs.cache.splitKeybinds |= ((. // []) | map(select( .value != "ptt" and .value != "lens" and .shortcut != [90] and .shortcut != [59, 90])) + [{"shortcut": [90], "value": "ptt"}, {"shortcut": [59, 90], "value": "lens"}]) JQ # Just the two places that matter, in a stable order, so a reordering by Flow # does not read as a change. bindings() { jq -S '{u: .prefs.user.shortcuts, c: (.prefs.cache.splitKeybinds // [] | sort_by(.value))}' "$1" } wanted="$(mktemp)" trap 'rm -f "$wanted"' EXIT jq "$filter" "$config" > "$wanted" if [[ "$(bindings "$config")" == "$(bindings "$wanted")" ]] then echo "Wispr Flow: Push to talk = F20, Command Mode = Ctrl+F20 (already set)" exit 0 fi # The main process only: the Swift helper inside the bundle has its own # "Wispr Flow.app" path, under Contents/Resources, and goes when this one does. bundle='^/Applications/Wispr Flow\.app/' main="${bundle}Contents/MacOS/Wispr Flow( |\$)" was_running="" if pgrep -f "$main" >/dev/null then was_running="y" # Flow ignores an AppleScript quit; a plain TERM shuts it down cleanly. echo "INFO: stopping Wispr Flow to edit its config" pkill -TERM -f "$main" # Every process in the bundle, helpers included: while any is still exiting, # the launch below finds the old instance and starts nothing. for _ in $(seq 1 60) do pgrep -f "$bundle" >/dev/null || break sleep 0.25 done if pgrep -f "$bundle" >/dev/null then >&2 echo "ERROR: Wispr Flow did not quit; shortcuts not set" exit 1 fi fi # Once, and never overwritten: the point is to keep the state from before this # script first touched the file, account and all, not the state from last run. if [[ ! -f "${config}.pre-wispr-keys" ]] then cp -p "$config" "${config}.pre-wispr-keys" echo "INFO: kept the original as ${config}.pre-wispr-keys" fi # Again, from what Flow wrote as it quit. Written in place rather than moved # over, so the file keeps its owner and mode. jq "$filter" "$config" > "$wanted" cat "$wanted" > "$config" echo "Wispr Flow: Push to talk = F20, Command Mode = Ctrl+F20" if [[ -n "$was_running" ]] then # By bundle id: the Swift helper inside the bundle is also an app named # "Wispr Flow", and `open -a "Wispr Flow"` can start that one instead -- it # exits at once, leaving Flow down. open -b com.electron.wispr-flow for _ in $(seq 1 40) do pgrep -f "$main" >/dev/null && exit 0 sleep 0.25 done >&2 echo "ERROR: Wispr Flow did not start again; open it by hand" exit 1 fi