Hyprland skips a release bind once any other key or mouse button is released while its key is held (KeybindManager::shadowKeybinds), and when the modifiers changed since the press. That is what makes a bind like "bindr = SUPER, SUPER_L" tap-only, but it means typing or clicking while holding F9 or Right Alt never runs omarchy-dictation stop, so the recording keeps going. o.bind_hold binds a press and a release action together. The release half is transparent (it can't be shadowed), ignores modifiers, and is non-consuming, so it doesn't take the key from apps when pressed with other modifiers (Shift+F9 still reaches the app). It runs only after its own press ran, so releasing the key without the rest of a chord does nothing. Dictation's push-to-talk keys use it. Co-authored-by: superduck00 <294879536+superduck00@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
356 lines
16 KiB
Bash
356 lines
16 KiB
Bash
#!/bin/bash
|
|
|
|
set -euo pipefail
|
|
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
|
|
|
test_tmp=$(mktemp -d)
|
|
trap 'rm -rf "$test_tmp"' EXIT
|
|
export HOME="$test_tmp/home" XDG_CONFIG_HOME="$test_tmp/config" OMARCHY_PATH="$ROOT"
|
|
export DICTATION_LOG="$test_tmp/calls" DICTATION_INSTALLED="voxtype superwhisper"
|
|
mkdir -p "$test_tmp/bin" "$XDG_CONFIG_HOME/omarchy/defaults"
|
|
export PATH="$test_tmp/bin:$ROOT/bin:$PATH"
|
|
config="$XDG_CONFIG_HOME/omarchy/defaults/dictation"
|
|
|
|
cat > "$test_tmp/bin/omarchy-cmd-present" <<'SH'
|
|
#!/bin/bash
|
|
case "$1" in
|
|
omarchy-dictation-*|omarchy-install-dictation-*) command -v "$1" >/dev/null ;;
|
|
*) [[ " $DICTATION_INSTALLED " == *" $1 "* ]] ;;
|
|
esac
|
|
SH
|
|
cat > "$test_tmp/bin/omarchy-cmd-missing" <<'SH'
|
|
#!/bin/bash
|
|
! omarchy-cmd-present "$1"
|
|
SH
|
|
cat > "$test_tmp/bin/omarchy-launch-floating-terminal-with-presentation" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s\n' "$*" > "$DICTATION_INSTALL_LOG"
|
|
SH
|
|
export DICTATION_INSTALL_LOG="$test_tmp/install"
|
|
for backend in voxtype superwhisper; do
|
|
cat > "$test_tmp/bin/$backend" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG"
|
|
if [[ $1 == "status" ]]; then echo "${VOXTYPE_STATUS:-idle}"; exit 0; fi
|
|
if [[ -n ${FAIL_SETUP_STEP:-} && $* == "$FAIL_SETUP_STEP" ]]; then exit 7; fi
|
|
if [[ ${0##*/} == "superwhisper" && $1 == "shortcuts" && ${DICTATION_EXIT:-0} == 0 ]]; then
|
|
if [[ $2 == "show" ]]; then
|
|
cat "$SHORTCUT_STATE"
|
|
elif [[ $2 == "set" ]]; then
|
|
chord=$4
|
|
[[ $chord != "none" ]] || chord=""
|
|
updated=$(jq --arg action "$3" --arg chord "$chord" '.shortcuts[$action] = $chord' "$SHORTCUT_STATE")
|
|
jq -e '.shortcuts | .hold != "RightAlt" and (.hold != "" or .toggle != "")' <<< "$updated" >/dev/null || exit 1
|
|
printf '%s\n' "$updated" > "$SHORTCUT_STATE"
|
|
fi
|
|
fi
|
|
exit "${DICTATION_EXIT:-0}"
|
|
SH
|
|
done
|
|
for command in omarchy-pkg-add omarchy-notification-send hyprctl omarchy-restart-shell; do
|
|
cat > "$test_tmp/bin/$command" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG"
|
|
exit "${SETUP_EXIT:-0}"
|
|
SH
|
|
done
|
|
cat > "$test_tmp/bin/systemctl" <<'SH'
|
|
#!/bin/bash
|
|
if [[ $* == "--user is-active --quiet superwhisper.service" ]]; then
|
|
[[ ${DICTATION_SERVICE_ACTIVE:-1} == 1 ]]
|
|
else
|
|
printf '%s %s\n' "${0##*/}" "$*" >> "$DICTATION_LOG"
|
|
exit "${SERVICE_EXIT:-0}"
|
|
fi
|
|
SH
|
|
cat > "$test_tmp/bin/omarchy-hw-vulkan" <<'SH'
|
|
#!/bin/bash
|
|
exit 1
|
|
SH
|
|
cat > "$test_tmp/bin/gum" <<'SH'
|
|
#!/bin/bash
|
|
exit "${CONFIRM_EXIT:-0}"
|
|
SH
|
|
cat > "$test_tmp/bin/bash" <<'SH'
|
|
#!/bin/bash
|
|
if [[ $1 == "/usr/share/superwhisper/setup-user" ]]; then
|
|
printf '%s\n' setup-user >> "$DICTATION_LOG"
|
|
exit "${SETUP_EXIT:-0}"
|
|
else
|
|
exec /bin/bash "$@"
|
|
fi
|
|
SH
|
|
chmod +x "$test_tmp/bin/"*
|
|
|
|
if omarchy-dictation start 2> "$test_tmp/error"; then
|
|
fail "unset backend must fail even when providers are installed"
|
|
fi
|
|
[[ ! -e $DICTATION_LOG ]] || fail "recording never configures or installs a backend"
|
|
pass "dictation requires explicit configuration"
|
|
omarchy-default-dictation superwhisper
|
|
[[ $(cat "$DICTATION_INSTALL_LOG") == "omarchy-install-dictation-superwhisper" ]] || fail "Defaults launches backend installer"
|
|
[[ ! -e $config ]] || fail "Defaults does not select before setup succeeds"
|
|
if omarchy-default-dictation unknown > "$test_tmp/output" 2>&1; then fail "missing installer must fail"; fi
|
|
if omarchy-default-dictation '../invalid' > "$test_tmp/output" 2>&1; then fail "invalid backend must fail"; fi
|
|
pass "Defaults delegates setup without prematurely selecting a backend"
|
|
|
|
|
|
for backend in voxtype superwhisper; do
|
|
printf '%s\n' "$backend" > "$config"
|
|
[[ $(omarchy-default-dictation) == "$backend" ]] || fail "configured backend is readable"
|
|
: > "$DICTATION_LOG"
|
|
for action in start stop toggle; do omarchy dictation "$action"; done
|
|
if [[ $backend == "voxtype" ]]; then
|
|
expected=$'voxtype record start\nvoxtype status\nvoxtype record stop\nvoxtype record toggle'
|
|
else
|
|
expected=$'superwhisper start\nsuperwhisper stop\nsuperwhisper record'
|
|
fi
|
|
[[ $(cat "$DICTATION_LOG") == "$expected" ]] || fail "adapter dispatches recording for $backend"
|
|
done
|
|
pass "built-in adapters implement start stop and toggle"
|
|
|
|
cat > "$test_tmp/bin/omarchy-dictation-future-backend" <<'SH'
|
|
#!/bin/bash
|
|
[[ ! -e /proc/$$/fd/9 ]] || exit 90
|
|
printf '%s\n' "$1" >> "$DICTATION_LOG"
|
|
exit "${DICTATION_EXIT:-0}"
|
|
SH
|
|
chmod +x "$test_tmp/bin/omarchy-dictation-future-backend"
|
|
cat > "$test_tmp/bin/omarchy-install-dictation-future-backend" <<'SH'
|
|
#!/bin/bash
|
|
exit 0
|
|
SH
|
|
chmod +x "$test_tmp/bin/omarchy-install-dictation-future-backend"
|
|
omarchy-default-dictation future-backend
|
|
[[ $(cat "$DICTATION_INSTALL_LOG") == "omarchy-install-dictation-future-backend" ]] || fail "third backend installer requires no selector changes"
|
|
printf '%s\n' future-backend > "$config"
|
|
: > "$DICTATION_LOG"
|
|
for action in start stop toggle; do omarchy-dictation "$action"; done
|
|
[[ $(cat "$DICTATION_LOG") == $'start\nstop\ntoggle' ]] || fail "third backend requires no dispatcher changes"
|
|
result=0
|
|
DICTATION_EXIT=7 omarchy-dictation start || result=$?
|
|
(( result == 7 )) || fail "backend failures propagate"
|
|
rm "$test_tmp/bin/omarchy-dictation-future-backend"
|
|
if omarchy-dictation start 2> "$test_tmp/error"; then fail "missing adapter must fail"; fi
|
|
printf '%s\n' '../invalid' > "$config"
|
|
if omarchy-dictation start 2> "$test_tmp/error"; then fail "invalid backend must fail"; fi
|
|
pass "external adapters follow the same protocol and errors propagate"
|
|
|
|
printf '%s\n' voxtype > "$config"
|
|
export SHORTCUT_STATE="$test_tmp/shortcuts.json"
|
|
printf '%s\n' '{"shortcuts":{"hold":"RightAlt","toggle":"","cancel":"Escape"}}' > "$SHORTCUT_STATE"
|
|
if SETUP_EXIT=1 omarchy-install-dictation-superwhisper > "$test_tmp/output" 2>&1; then fail "failed installation must fail"; fi
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "failed setup must preserve selection"
|
|
if DICTATION_EXIT=7 omarchy-install-dictation-superwhisper > "$test_tmp/output" 2>&1; then fail "failed shortcut setup must fail"; fi
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "failed shortcuts must preserve selection"
|
|
omarchy-install-dictation-superwhisper
|
|
[[ $(omarchy-default-dictation) == "superwhisper" ]] || fail "installer selects Superwhisper"
|
|
jq -e '.shortcuts | .hold == "" and .toggle == "Alt+Space"' "$SHORTCUT_STATE" >/dev/null || fail "installer repairs conflicting hold-only profiles"
|
|
printf '%s\n' '{"shortcuts":{"hold":"RightAlt","toggle":"RightSuper","cancel":"Escape"}}' > "$SHORTCUT_STATE"
|
|
omarchy-install-dictation-superwhisper
|
|
jq -e '.shortcuts | .hold == "" and .toggle == "Alt+Space"' "$SHORTCUT_STATE" >/dev/null || fail "installer repairs conflicting profiles with an existing toggle"
|
|
omarchy-install-dictation-superwhisper
|
|
pass "Superwhisper installer owns setup and saves selection only after success"
|
|
|
|
DICTATION_INSTALLED=superwhisper
|
|
CONFIRM_EXIT=1 omarchy-install-dictation-voxtype
|
|
[[ $(cat "$config") == "superwhisper" ]] || fail "cancelled installation preserves selection"
|
|
omarchy-install-dictation-voxtype
|
|
[[ $(omarchy-default-dictation) == "voxtype" ]] || fail "installer selects Voxtype"
|
|
DICTATION_INSTALLED="voxtype superwhisper"
|
|
: > "$DICTATION_LOG"
|
|
omarchy-install-dictation-voxtype
|
|
grep -q 'voxtype setup --download --no-post-install' "$DICTATION_LOG" || fail "installed Voxtype retries model setup"
|
|
printf '%s\n' 'custom model configuration' > "$XDG_CONFIG_HOME/voxtype/config.toml"
|
|
printf '%s\n' superwhisper > "$config"
|
|
for step in 'setup --download --no-post-install' 'setup systemd'; do
|
|
if FAIL_SETUP_STEP="$step" omarchy-install-dictation-voxtype > "$test_tmp/output" 2>&1; then fail "incomplete setup must fail"; fi
|
|
[[ $(cat "$config") == "superwhisper" ]] || fail "incomplete setup preserves selection"
|
|
done
|
|
if SERVICE_EXIT=1 omarchy-install-dictation-voxtype > "$test_tmp/output" 2>&1; then fail "service failure must fail"; fi
|
|
[[ $(cat "$config") == "superwhisper" ]] || fail "service failure preserves selection"
|
|
omarchy-install-dictation-voxtype
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "retry finishes installed Voxtype setup"
|
|
[[ $(cat "$XDG_CONFIG_HOME/voxtype/config.toml") == "custom model configuration" ]] || fail "retry preserves custom configuration"
|
|
pass "Voxtype retries incomplete model and service setup without replacing configuration"
|
|
|
|
: > "$DICTATION_LOG"
|
|
if DICTATION_EXIT=7 omarchy-dictation-use superwhisper > "$test_tmp/output" 2>&1; then fail "failed stop must prevent switching"; fi
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "failed stop preserves selection"
|
|
omarchy-dictation-use superwhisper
|
|
[[ $(cat "$DICTATION_LOG") == $'voxtype status\nvoxtype record stop\nvoxtype status\nvoxtype record stop' ]] || fail "switch stops the previous backend"
|
|
[[ $(cat "$config") == "superwhisper" ]] || fail "successful stop permits switching"
|
|
: > "$DICTATION_LOG"
|
|
omarchy-dictation-use superwhisper
|
|
[[ ! -s $DICTATION_LOG ]] || fail "reselecting does not interrupt recording"
|
|
printf '%s\n' voxtype > "$config"
|
|
VOXTYPE_STATUS=stopped omarchy-dictation-use superwhisper
|
|
[[ $(cat "$config") == "superwhisper" ]] || fail "a stopped Voxtype daemon permits switching"
|
|
printf '%s\n' superwhisper > "$config"
|
|
DICTATION_SERVICE_ACTIVE=0 omarchy-dictation-use voxtype
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "a stopped Superwhisper service permits switching"
|
|
printf '%s\n' removed-backend > "$config"
|
|
omarchy-dictation-use voxtype
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "a removed adapter permits recovery"
|
|
pass "switching stops recordings, preserves failed stops, and recovers unavailable providers"
|
|
|
|
# Readers may run throughout a switch, but must never observe a truncated file.
|
|
(
|
|
for ((i = 0; i < 200; i++)); do
|
|
selection=$(cat "$config")
|
|
[[ $selection == "voxtype" || $selection == "superwhisper" ]] || exit 1
|
|
done
|
|
) &
|
|
reader=$!
|
|
for ((i = 0; i < 20; i++)); do
|
|
omarchy-dictation-use voxtype
|
|
omarchy-dictation-use superwhisper
|
|
done
|
|
wait "$reader" || fail "concurrent readers always see a complete selection"
|
|
printf '%s\n' voxtype > "$config"
|
|
pass "selection updates are atomic for concurrent readers"
|
|
|
|
lua <<'LUA'
|
|
local root = os.getenv("ROOT")
|
|
local configured, bindings = false, {}
|
|
o = {
|
|
shell_succeeds = function(command)
|
|
assert(command == "omarchy-default-dictation")
|
|
return configured
|
|
end,
|
|
bind = function(...) table.insert(bindings, {...}) end,
|
|
bind_hold = function(keys, _, press, _, release, options)
|
|
table.insert(bindings, { keys, press, options })
|
|
table.insert(bindings, { keys, release, options })
|
|
end,
|
|
}
|
|
dofile(root .. "/default/hypr/bindings/dictation.lua")
|
|
assert(#bindings == 0, "unconfigured dictation leaves application keys available")
|
|
configured = true
|
|
dofile(root .. "/default/hypr/bindings/dictation.lua")
|
|
assert(#bindings == 5, "any configured backend receives the same bindings")
|
|
local function binds(keys, command)
|
|
for _, binding in ipairs(bindings) do
|
|
if binding[1] == keys and binding[2] == command then return true end
|
|
end
|
|
end
|
|
for _, keys in ipairs({ "F9", "ALT + Alt_R" }) do
|
|
assert(binds(keys, "omarchy-dictation start") and binds(keys, "omarchy-dictation stop"),
|
|
keys .. " starts dictation on press and stops it on release")
|
|
end
|
|
LUA
|
|
pass "dictation shortcuts require a selection without limiting backend names"
|
|
|
|
# Fresh users receive the backend choice through the settings package's skel.
|
|
cp "$ROOT/config/omarchy/defaults/dictation" "$config"
|
|
[[ $(omarchy-default-dictation) == "superwhisper" ]] || fail "fresh users default to Superwhisper"
|
|
printf '%s\n' voxtype > "$config"
|
|
pass "fresh users get Superwhisper as their default backend"
|
|
|
|
lua <<'LUA'
|
|
local root = os.getenv("ROOT")
|
|
package.path = root .. "/?.lua;" .. package.path
|
|
package.loaded["default.hypr.paths"] = { config_home = "/config" }
|
|
local selected, available, loaded = "superwhisper", true, {}
|
|
local missing = false
|
|
o = { cmd_missing = function() return missing end }
|
|
local real_open, real_popen, real_dofile = io.open, io.popen, dofile
|
|
io.popen = function(command)
|
|
assert(command == "omarchy-default-dictation 2>/dev/null")
|
|
return { read = function() return selected end, close = function() end }
|
|
end
|
|
io.open = function(path)
|
|
assert(path == "/config/" .. selected .. "/shortcuts.lua")
|
|
if available then return { close = function() end } end
|
|
end
|
|
dofile = function(path) table.insert(loaded, path) end
|
|
local function load_backend()
|
|
package.loaded["default.hypr.dictation-backend"] = nil
|
|
require("default.hypr.dictation-backend")
|
|
end
|
|
load_backend()
|
|
assert(loaded[1] == "/config/superwhisper/shortcuts.lua")
|
|
selected, available = "voxtype", false
|
|
load_backend()
|
|
assert(#loaded == 1, "a backend with no integration should need no config")
|
|
selected, available = "future-backend", true
|
|
load_backend()
|
|
assert(loaded[2] == "/config/future-backend/shortcuts.lua", "integration must not be limited to named providers")
|
|
missing = true
|
|
load_backend()
|
|
assert(#loaded == 2, "an uninstalled backend must not load a stale bridge")
|
|
missing = false
|
|
dofile = function() error("broken generated bridge") end
|
|
load_backend()
|
|
assert(#loaded == 2, "a broken bridge must not abort the desktop configuration")
|
|
dofile = function(path) table.insert(loaded, path) end
|
|
selected = "../../outside"
|
|
load_backend()
|
|
assert(#loaded == 2, "a backend must not escape the config directory")
|
|
selected = nil
|
|
load_backend()
|
|
assert(#loaded == 2, "no installed backend must leave configuration usable")
|
|
io.open, io.popen, dofile = real_open, real_popen, real_dofile
|
|
LUA
|
|
pass "desktop integration follows the selected backend without personal Hyprland config"
|
|
|
|
cat > "$test_tmp/bin/systemctl" <<'SH'
|
|
#!/bin/bash
|
|
exit 0
|
|
SH
|
|
cat > "$test_tmp/bin/omarchy-pkg-drop" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s\n' "$*" >> "$DICTATION_LOG"
|
|
exit "${REMOVE_EXIT:-0}"
|
|
SH
|
|
chmod +x "$test_tmp/bin/systemctl" "$test_tmp/bin/omarchy-pkg-drop"
|
|
omarchy-remove-dictation-voxtype
|
|
[[ ! -e $config ]] || fail "removing Voxtype clears selection"
|
|
if omarchy-default-dictation 2> "$test_tmp/error"; then fail "removal must not auto-select another backend"; fi
|
|
pass "removal clears selection without automatic fallback"
|
|
|
|
cat > "$test_tmp/bin/omarchy-shell" <<'SH'
|
|
#!/bin/bash
|
|
printf '%s\n' "$*" >> "$DICTATION_LOG"
|
|
SH
|
|
chmod +x "$test_tmp/bin/omarchy-shell"
|
|
export XDG_DATA_HOME="$test_tmp/data"
|
|
mkdir -p "$HOME/.local/bin" "$XDG_DATA_HOME/superwhisper/app" "$XDG_DATA_HOME/superwhisper/models" "$XDG_CONFIG_HOME/omarchy/plugins" "$HOME/.agents/skills" "$HOME/.claude/skills"
|
|
ln -s /usr/bin/superwhisper "$HOME/.local/bin/superwhisper"
|
|
ln -s /opt/superwhisper "$XDG_DATA_HOME/superwhisper/app/current"
|
|
ln -s /opt/superwhisper/assets/omarchy-plugin/superwhisper-panel "$XDG_CONFIG_HOME/omarchy/plugins/superwhisper-panel"
|
|
ln -s /opt/superwhisper/assets/agent-skill/superwhisper "$HOME/.agents/skills/superwhisper"
|
|
ln -s /custom/skill "$HOME/.claude/skills/superwhisper"
|
|
printf '%s\n' retained > "$XDG_DATA_HOME/superwhisper/models/model"
|
|
printf '%s\n' superwhisper > "$config"
|
|
if REMOVE_EXIT=1 omarchy-remove-dictation-superwhisper > "$test_tmp/output" 2>&1; then
|
|
fail "failed package removal must fail"
|
|
fi
|
|
[[ -L $HOME/.local/bin/superwhisper && $(cat "$config") == "superwhisper" ]] || fail "failed removal preserves selection and links"
|
|
omarchy-remove-dictation-superwhisper
|
|
[[ ! -e $config ]] || fail "Superwhisper removal clears its selection"
|
|
for link in "$HOME/.local/bin/superwhisper" "$XDG_DATA_HOME/superwhisper/app/current" "$XDG_CONFIG_HOME/omarchy/plugins/superwhisper-panel" "$HOME/.agents/skills/superwhisper"; do
|
|
[[ ! -L $link ]] || fail "Superwhisper removal unlinks packaged integration"
|
|
done
|
|
[[ $(readlink "$HOME/.claude/skills/superwhisper") == "/custom/skill" ]] || fail "custom skills are preserved"
|
|
[[ -f $XDG_DATA_HOME/superwhisper/models/model ]] || fail "downloaded models are retained"
|
|
printf '%s\n' voxtype > "$config"
|
|
omarchy-remove-dictation-superwhisper
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "removing Superwhisper preserves another selected backend"
|
|
pass "Superwhisper removal clears its selection and owned links while preserving user data and other backends"
|
|
|
|
cat > "$test_tmp/bin/omarchy-pkg-present" <<'SH'
|
|
#!/bin/bash
|
|
[[ ${VOXTYPE_PACKAGE:-0} == 1 && $1 == "voxtype-bin" ]]
|
|
SH
|
|
chmod +x "$test_tmp/bin/omarchy-pkg-present"
|
|
rm "$config"
|
|
VOXTYPE_PACKAGE=0 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh"
|
|
[[ ! -e $config ]] || fail "migration does not select an uninstalled backend"
|
|
VOXTYPE_PACKAGE=1 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh"
|
|
[[ $(cat "$config") == "voxtype" ]] || fail "existing Voxtype users keep dictation after upgrade"
|
|
printf '%s\n' future-backend > "$config"
|
|
VOXTYPE_PACKAGE=1 /bin/bash -euo pipefail "$ROOT/migrations/1791479273.sh"
|
|
[[ $(cat "$config") == "future-backend" ]] || fail "upgrade preserves explicit backend selections"
|
|
pass "one-time upgrade preserves existing Voxtype without runtime autodetection"
|