summaryrefslogtreecommitdiff
path: root/bin/wispr-keys
diff options
context:
space:
mode:
Diffstat (limited to 'bin/wispr-keys')
-rwxr-xr-xbin/wispr-keys135
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