summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-01 21:24:09 +0300
committerIgor Soarez <igor@soarez.org>2026-10-01 21:24:09 +0300
commit5919160d02bca068a3ea8c30160013aad8236700 (patch)
tree925e45e6fa9731483656f399efc553d62455b151
parentcf4c4424a695235f7288a64ab47e6f71d9e23dc0 (diff)
bin/wispr-keys: Flow dictation on the Caps Lock key
Push to talk on F20, Command Mode on Ctrl+F20, written into Flow's config.json because its settings screen refuses F20 without a modifier while its key listener accepts it. The key codes and action names come from reading Flow 1.6.1021's app.asar: shortcuts are keyed by macOS key codes string-sorted and joined with "+", f20 is 90, ctrl is 59, ptt is Push to talk and lens is Command Mode. splitKeybinds is kept in step so Flow cannot read back a disagreeing pair. A bin/ tool rather than a link/ part, though it was proposed as one. It has to quit and restart Flow for an edit to stick, and link.sh runs unattended after every sync: a part that can kill a dictation mid-sentence is a poor trade for a binding that can wait until someone asks for it. On demand it is also free to fail loudly, since nothing else's run depends on it. Flow ignores an AppleScript quit, so it gets a TERM; and it is launched by bundle id, because the bundle contains a second app also called "Wispr Flow" -- its Swift helper -- that `open -a` can start instead, which exits at once. The file as first found is kept as config.json.pre-wispr-keys.
-rw-r--r--AGENTS.md14
-rwxr-xr-xbin/wispr-keys135
2 files changed, 149 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 097985c..22da5dc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -390,12 +390,26 @@ shadowing cannot work, because the remap has already run by the time
reports whether the watchers and the tap are still up; that needs `hs.ipc`,
which `init.lua` requires for exactly this kind of check.
+Wispr Flow dictates from that key, set by `bin/wispr-keys`: Push to talk on F20,
+Command Mode on Ctrl+F20, written into Flow's own `config.json` because its
+settings screen refuses F20 without a modifier while its key listener accepts
+it. 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 it sets. It
+keeps the file it first found as `config.json.pre-wispr-keys`.
+
+**It is not a `link/` part, and should not become one.** It has to quit and
+restart Flow to make an edit stick, and `link.sh` runs unattended after every
+sync — a part that can kill a dictation in progress is the wrong trade for a
+binding that can wait until someone asks. That is also why it exits non-zero
+when Flow will not restart: nothing else's run depends on it.
+
### Utility Scripts
The `bin/` directory contains various utility scripts for:
- Theme management (`theme`)
- Notification sending (`notify`)
- Development tools (`vi` wrapper, `smack`, `pyexec`)
- System utilities (`wifi`, `default-browser`, `truecolour-test`, `open` — macOS-style `open` for Linux desktops; hands over to `/usr/bin/open` on macOS)
+- App settings that no `defaults` key reaches (`wispr-keys` — Flow's dictation shortcuts; see the Keyboard section for why it lives here and not in `link/`)
## Development Notes
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