bin/wispr-keys (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 |
#!/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
|