summaryrefslogtreecommitdiff
path: root/bin/wispr-keys
blob: 0936c22ee96493724e7dba293bd2f0a94898f733 (plain)
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