diff options
Diffstat (limited to 'bin/wispr-keys')
| -rwxr-xr-x | bin/wispr-keys | 135 |
1 files changed, 135 insertions, 0 deletions
diff --git a/bin/wispr-keys b/bin/wispr-keys new file mode 100755 index 0000000..0936c22 --- /dev/null +++ b/bin/wispr-keys @@ -0,0 +1,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 |
