diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-01 21:24:09 +0300 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-01 21:24:09 +0300 |
| commit | 5919160d02bca068a3ea8c30160013aad8236700 (patch) | |
| tree | 925e45e6fa9731483656f399efc553d62455b151 | |
| parent | cf4c4424a695235f7288a64ab47e6f71d9e23dc0 (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.md | 14 | ||||
| -rwxr-xr-x | bin/wispr-keys | 135 |
2 files changed, 149 insertions, 0 deletions
@@ -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 |
