From c24889096e12fbb053df1308877adefc3da1caac Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 8 Oct 2026 20:37:26 +0200 Subject: [PATCH] Add selectable Voxtype and Superwhisper dictation (#14552) * Add selectable dictation backends * Move dictation migration after repository priority update * Extract backend-specific dictation installers * Keep dictation installer dispatcher executable * Call dictation backend installers directly * Drop legacy Superwhisper migration and runtime cleanup * Separate dictation defaults from generic recording protocol * Add backend-specific dictation removers * Preserve existing Voxtype selection during upgrade * Make dictation backend selection safe and retryable * Show the dictation indicator only for selected Voxtype * Allow switching away from stopped Superwhisper * Remove the built-in bar dictation indicator * Store default dictation selection under defaults * Rename dictation backend selector to use * Move dictation removers into their own menu category --- bin/omarchy | 1 + bin/omarchy-default-dictation | 37 ++ bin/omarchy-dictation | 26 ++ bin/omarchy-dictation-superwhisper | 17 + bin/omarchy-dictation-use | 33 ++ bin/omarchy-dictation-voxtype | 16 + bin/omarchy-install-dictation-superwhisper | 17 + bin/omarchy-install-dictation-voxtype | 33 ++ bin/omarchy-remove-dictation-superwhisper | 40 +++ ...emove => omarchy-remove-dictation-voxtype} | 4 + bin/omarchy-voxtype-install | 26 -- bin/omarchy-voxtype-status | 13 - default/hypr/bindings/dictation.lua | 9 + default/hypr/bindings/voxtype.lua | 5 - default/hypr/dictation-backend.lua | 24 ++ default/hypr/omarchy.lua | 3 +- default/omarchy/omarchy-menu.jsonc | 8 +- default/voxtype/config.toml | 2 +- install/user/first-run/install-voxtype.hook | 4 +- manual/07-hotkeys.md | 4 +- manual/11-text-extraction-dictation.md | 12 +- migrations/1791479273.sh | 9 + shell/plugins/bar/indicators/Dictation.qml | 38 --- .../bar/widgets/Indicators.manifest.json | 5 - shell/plugins/bar/widgets/Indicators.qml | 2 +- shell/plugins/menu/MenuModel.js | 1 + test/shell.d/dictation-test.sh | 318 ++++++++++++++++++ .../fixtures/indicator-contract/shell.qml | 10 - .../hyprland-binding-conflicts-test.sh | 7 +- test/shell.d/hyprland-default-config-test.sh | 45 ++- test/shell.d/menu-guards-test.sh | 2 +- test/shell.d/menu-test.sh | 3 +- .../passwordless-sudo-indicator-test.sh | 2 +- test/shell.d/voxtype-invitation-test.sh | 4 +- 34 files changed, 639 insertions(+), 141 deletions(-) create mode 100755 bin/omarchy-default-dictation create mode 100755 bin/omarchy-dictation create mode 100755 bin/omarchy-dictation-superwhisper create mode 100755 bin/omarchy-dictation-use create mode 100755 bin/omarchy-dictation-voxtype create mode 100755 bin/omarchy-install-dictation-superwhisper create mode 100755 bin/omarchy-install-dictation-voxtype create mode 100755 bin/omarchy-remove-dictation-superwhisper rename bin/{omarchy-voxtype-remove => omarchy-remove-dictation-voxtype} (75%) delete mode 100755 bin/omarchy-voxtype-install delete mode 100755 bin/omarchy-voxtype-status create mode 100644 default/hypr/bindings/dictation.lua delete mode 100644 default/hypr/bindings/voxtype.lua create mode 100644 default/hypr/dictation-backend.lua create mode 100644 migrations/1791479273.sh delete mode 100644 shell/plugins/bar/indicators/Dictation.qml create mode 100644 test/shell.d/dictation-test.sh diff --git a/bin/omarchy b/bin/omarchy index 35b8094a..b0146759 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -46,6 +46,7 @@ GROUP_DESCRIPTIONS[finalize]="Finalize user setup" GROUP_DESCRIPTIONS[default]="Default application selection" GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[disk]="Disk performance helpers" +GROUP_DESCRIPTIONS[dictation]="Dictation recording" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" diff --git a/bin/omarchy-default-dictation b/bin/omarchy-default-dictation new file mode 100755 index 00000000..ce22b7fc --- /dev/null +++ b/bin/omarchy-default-dictation @@ -0,0 +1,37 @@ +#!/bin/bash + +# omarchy:summary=Get or set the default dictation backend +# omarchy:args=[backend] +# omarchy:examples=omarchy default dictation | omarchy default dictation superwhisper + +if (($# > 1)); then + echo "Usage: omarchy default dictation [backend]" >&2 + exit 1 +fi + +if (($# == 0)); then + config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" + if [[ ! -s $config ]]; then + echo "No dictation backend configured. Select one in Setup > Defaults > Dictation." >&2 + exit 1 + fi + read -r backend < "$config" +else + backend=$1 +fi + +if [[ ! $backend =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Invalid dictation backend: $backend" >&2 + exit 1 +fi + +if (($# == 0)); then + echo "$backend" +else + installer="omarchy-install-dictation-$backend" + if omarchy-cmd-missing "$installer"; then + echo "No installer available for dictation backend: $backend" >&2 + exit 1 + fi + omarchy-launch-floating-terminal-with-presentation "$installer" +fi diff --git a/bin/omarchy-dictation b/bin/omarchy-dictation new file mode 100755 index 00000000..dd5e43c1 --- /dev/null +++ b/bin/omarchy-dictation @@ -0,0 +1,26 @@ +#!/bin/bash + +# omarchy:summary=Record dictation with the configured backend +# omarchy:args= +# omarchy:examples=omarchy dictation start | omarchy dictation toggle + +if (($# != 1)) || [[ $1 != "start" && $1 != "stop" && $1 != "toggle" ]]; then + echo "Usage: omarchy dictation " >&2 + exit 1 +fi + +config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +mkdir -p "${config%/*}" +exec 9> "$config.lock" +if ! flock -w 5 9; then + echo "Dictation is busy; try again." >&2 + exit 1 +fi + +backend=$(omarchy-default-dictation) || exit 1 +adapter="omarchy-dictation-$backend" +if omarchy-cmd-missing "$adapter"; then + echo "The selected dictation backend ($backend) is not installed." >&2 + exit 1 +fi +"$adapter" "$1" 9>&- diff --git a/bin/omarchy-dictation-superwhisper b/bin/omarchy-dictation-superwhisper new file mode 100755 index 00000000..a6a8c9ad --- /dev/null +++ b/bin/omarchy-dictation-superwhisper @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Record dictation using Superwhisper +# omarchy:args= + +case "${1:-}" in + start) superwhisper start ;; + stop) + if omarchy-cmd-missing superwhisper || ! systemctl --user is-active --quiet superwhisper.service; then + exit 0 + else + superwhisper stop + fi + ;; + toggle) superwhisper record ;; + *) echo "Usage: omarchy dictation superwhisper " >&2; exit 1 ;; +esac diff --git a/bin/omarchy-dictation-use b/bin/omarchy-dictation-use new file mode 100755 index 00000000..db8e3405 --- /dev/null +++ b/bin/omarchy-dictation-use @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:hidden=true +# omarchy:summary=Save a configured dictation backend after stopping the previous backend +# omarchy:args= + +set -e + +if (($# != 1)) || [[ ! $1 =~ ^[a-z0-9][a-z0-9-]*$ ]]; then + echo "Usage: omarchy-dictation-use " >&2 + exit 1 +fi + +config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +mkdir -p "${config%/*}" +exec 9> "$config.lock" +if ! flock -w 5 9; then + echo "Dictation is busy; try again." >&2 + exit 1 +fi + +previous=$(omarchy-default-dictation 2>/dev/null) || previous="" +if [[ -n $previous && $previous != "$1" ]] && omarchy-cmd-present "omarchy-dictation-$previous"; then + if ! "omarchy-dictation-$previous" stop 9>&-; then + echo "Could not stop $previous; keeping the existing dictation backend." >&2 + exit 1 + fi +fi + +temporary=$(mktemp "$config.XXXXXX") +trap 'rm -f "$temporary"' EXIT +printf '%s\n' "$1" > "$temporary" +mv -f "$temporary" "$config" diff --git a/bin/omarchy-dictation-voxtype b/bin/omarchy-dictation-voxtype new file mode 100755 index 00000000..33f27869 --- /dev/null +++ b/bin/omarchy-dictation-voxtype @@ -0,0 +1,16 @@ +#!/bin/bash + +# omarchy:summary=Record dictation using Voxtype +# omarchy:args= + +case "${1:-}" in + start|toggle) voxtype record "$1" ;; + stop) + if omarchy-cmd-missing voxtype || [[ $(voxtype status) == "stopped" ]]; then + exit 0 + else + voxtype record stop + fi + ;; + *) echo "Usage: omarchy dictation voxtype " >&2; exit 1 ;; +esac diff --git a/bin/omarchy-install-dictation-superwhisper b/bin/omarchy-install-dictation-superwhisper new file mode 100755 index 00000000..f2835e55 --- /dev/null +++ b/bin/omarchy-install-dictation-superwhisper @@ -0,0 +1,17 @@ +#!/bin/bash + +# omarchy:summary=Install and configure Superwhisper dictation +# omarchy:requires-sudo=true + +set -e + +omarchy-pkg-add superwhisper-bin +bash /usr/share/superwhisper/setup-user +# Superwhisper requires a native recording shortcut; keep it separate from +# Omarchy's push-to-talk keys. +superwhisper shortcuts set toggle "Alt+Space" >/dev/null +superwhisper shortcuts set hold none >/dev/null +omarchy-dictation-use superwhisper +hyprctl reload >/dev/null +omarchy-notification-send -g  "Superwhisper Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 +superwhisper settings diff --git a/bin/omarchy-install-dictation-voxtype b/bin/omarchy-install-dictation-voxtype new file mode 100755 index 00000000..79011e1d --- /dev/null +++ b/bin/omarchy-install-dictation-voxtype @@ -0,0 +1,33 @@ +#!/bin/bash + +# omarchy:summary=Install and configure Voxtype dictation +# omarchy:requires-sudo=true + +set -e + +if omarchy-cmd-missing voxtype; then + if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then + omarchy-pkg-add wtype voxtype-bin + else + exit 0 + fi +fi + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +mkdir -p "$config_home/voxtype" +if [[ ! -e $config_home/voxtype/config.toml ]]; then + cp "$OMARCHY_PATH/default/voxtype/config.toml" "$config_home/voxtype/" + if omarchy-hw-vulkan; then + voxtype setup gpu --enable || true + fi +fi + +# Setup reuses the configured model and downloads it only when missing. +# Retry these steps even when an earlier attempt already installed the binary. +voxtype setup --download --no-post-install +voxtype setup systemd +systemctl --user enable --now voxtype.service + +omarchy-dictation-use voxtype +hyprctl reload >/dev/null +omarchy-notification-send -g  "Voxtype Dictation Ready" "Hold Right Alt or F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 diff --git a/bin/omarchy-remove-dictation-superwhisper b/bin/omarchy-remove-dictation-superwhisper new file mode 100755 index 00000000..eeff247c --- /dev/null +++ b/bin/omarchy-remove-dictation-superwhisper @@ -0,0 +1,40 @@ +#!/bin/bash + +# omarchy:summary=Remove Superwhisper dictation and its desktop integration +# omarchy:requires-sudo=true + +set -e + +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" +data_home="${XDG_DATA_HOME:-$HOME/.local/share}" + +systemctl --user disable --now superwhisper.service 2>/dev/null || true +omarchy-shell -q shell setPluginEnabled superwhisper.panel false >/dev/null +omarchy-pkg-drop superwhisper-bin +systemctl --user daemon-reload + +# Remove the package setup's links, preserving independently installed copies +# and the user's preferences, history, and models. +unlink_package_link() { + if [[ -L $1 && $(readlink "$1") == "$2" ]]; then + rm "$1" + fi +} + +unlink_package_link "$HOME/.local/bin/superwhisper" /usr/bin/superwhisper +unlink_package_link "$data_home/superwhisper/app/current" /opt/superwhisper +unlink_package_link "$config_home/omarchy/plugins/superwhisper-panel" /opt/superwhisper/assets/omarchy-plugin/superwhisper-panel +for agent in .agents .claude .codex .pi/agent; do + unlink_package_link "$HOME/$agent/skills/superwhisper" /opt/superwhisper/assets/agent-skill/superwhisper +done + +backend_config="$config_home/omarchy/defaults/dictation" +if [[ -f $backend_config && $(< "$backend_config") == "superwhisper" ]]; then + rm "$backend_config" +fi + +omarchy-shell -q shell rescanPlugins +hyprctl reload >/dev/null +if systemctl --user -q is-active omarchy-fcitx5.service; then + systemctl --user restart omarchy-fcitx5.service +fi diff --git a/bin/omarchy-voxtype-remove b/bin/omarchy-remove-dictation-voxtype similarity index 75% rename from bin/omarchy-voxtype-remove rename to bin/omarchy-remove-dictation-voxtype index 08634e60..59abafcf 100755 --- a/bin/omarchy-voxtype-remove +++ b/bin/omarchy-remove-dictation-voxtype @@ -18,6 +18,10 @@ if omarchy-cmd-present voxtype; then omarchy-pkg-drop voxtype-bin rm -rf ~/.config/voxtype rm -rf ~/.local/share/voxtype + backend_config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" + if [[ -f $backend_config && $(< "$backend_config") == "voxtype" ]]; then + rm "$backend_config" + fi hyprctl reload >/dev/null else echo "Voxtype was not installed." diff --git a/bin/omarchy-voxtype-install b/bin/omarchy-voxtype-install deleted file mode 100755 index 1fd19f9d..00000000 --- a/bin/omarchy-voxtype-install +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install and configure Voxtype dictation -# omarchy:requires-sudo=true - -set -e - -# Install voxtype and configure it for use. - -if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then - omarchy-pkg-add wtype voxtype-bin - - # Setup voxtype - mkdir -p ~/.config/voxtype - cp "$OMARCHY_PATH/default/voxtype/config.toml" ~/.config/voxtype/ - - voxtype setup --download --no-post-install - if omarchy-hw-vulkan; then - voxtype setup gpu --enable || true - fi - voxtype setup systemd - - hyprctl reload >/dev/null - omarchy-restart-shell - omarchy-notification-send -g  "Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000 -fi diff --git a/bin/omarchy-voxtype-status b/bin/omarchy-voxtype-status deleted file mode 100755 index f3131483..00000000 --- a/bin/omarchy-voxtype-status +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Stream voxtype --follow status as bar-friendly JSON - -if omarchy-cmd-missing voxtype; then - echo '{"alt": "", "class": "idle", "tooltip": ""}' - exit 0 -fi - -# Keep the status follower as the direct child of the bar. Quickshell -# clean up their direct child when they restart; a shell pipeline/coproc would -# leave `voxtype status --follow` orphaned under the user systemd instance. -exec setpriv --pdeathsig TERM voxtype status --follow --extended --format json diff --git a/default/hypr/bindings/dictation.lua b/default/hypr/bindings/dictation.lua new file mode 100644 index 00000000..b486d794 --- /dev/null +++ b/default/hypr/bindings/dictation.lua @@ -0,0 +1,9 @@ +if o.shell_succeeds("omarchy-default-dictation") then + o.bind("SUPER + CTRL + X", "Toggle dictation", "omarchy-dictation toggle") + o.bind("F9", "Start dictation (push-to-talk)", "omarchy-dictation start") + o.bind("F9", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true }) + -- A modifier's mask changes between its press and release. Match the keysym + -- independently of that mask; AltGr layouts use a different keysym. + o.bind("ALT + Alt_R", "Start dictation (push-to-talk)", "omarchy-dictation start", { ignore_mods = true }) + o.bind("ALT + Alt_R", "Stop dictation (push-to-talk)", "omarchy-dictation stop", { release = true, ignore_mods = true }) +end diff --git a/default/hypr/bindings/voxtype.lua b/default/hypr/bindings/voxtype.lua deleted file mode 100644 index df594b83..00000000 --- a/default/hypr/bindings/voxtype.lua +++ /dev/null @@ -1,5 +0,0 @@ -if o.cmd_present("voxtype") then - o.bind("SUPER + CTRL + X", "Toggle dictation", "voxtype record toggle") - o.bind("F9", "Start dictation (push-to-talk)", "voxtype record start") - o.bind("F9", "Stop dictation (push-to-talk)", "voxtype record stop", { release = true }) -end diff --git a/default/hypr/dictation-backend.lua b/default/hypr/dictation-backend.lua new file mode 100644 index 00000000..38f22605 --- /dev/null +++ b/default/hypr/dictation-backend.lua @@ -0,0 +1,24 @@ +-- Backends may supply their own desktop integration without editing the +-- user's Hyprland config. Recording bindings remain owned by Omarchy. +local paths = require("default.hypr.paths") +local pipe = io.popen("omarchy-default-dictation 2>/dev/null", "r") +if not pipe then + return +end + +local backend = pipe:read("*l") +pipe:close() + +if not backend or not backend:match("^[%w%-]+$") or o.cmd_missing("omarchy-dictation-" .. backend) then + return +end + +local integration = paths.config_home .. "/" .. backend .. "/shortcuts.lua" +local file = io.open(integration, "r") +if file then + file:close() + local ok, error = pcall(dofile, integration) + if not ok then + print("Could not load dictation backend " .. backend .. ": " .. tostring(error)) + end +end diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index abc7c4e0..51de2bf6 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -10,7 +10,7 @@ if _G.omarchy_default_bindings ~= false then require("default.hypr.bindings.clipboard") require("default.hypr.bindings.tiling") require("default.hypr.bindings.utilities") - require("default.hypr.bindings.voxtype") + require("default.hypr.bindings.dictation") require_optional.module("default.hypr.bindings.applications") end require("default.hypr.envs") @@ -18,6 +18,7 @@ require("default.hypr.looknfeel") require("default.hypr.qconsole") require("default.hypr.input") require("default.hypr.windows") +require("default.hypr.dictation-backend") -- Current theme overrides. require_optional.module("omarchy.current.theme.hyprland") diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 6cd399f3..27e390c7 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -159,6 +159,9 @@ "setup.default.browser.edge": {"icon":"󰇩","label":"Edge","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"}, "setup.default.browser.firefox": {"icon":"","label":"Firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, + "setup.default.dictation": {"icon":"","label":"Dictation","title":"Default Dictation"}, + "setup.default.dictation.voxtype": {"icon":"","label":"Voxtype","checked":"[[ \"$(omarchy-default-dictation)\" == \"voxtype\" ]]","action":"omarchy-default-dictation voxtype"}, + "setup.default.dictation.superwhisper": {"icon":"","label":"Superwhisper","checked":"[[ \"$(omarchy-default-dictation)\" == \"superwhisper\" ]]","action":"omarchy-default-dictation superwhisper"}, "setup.default.terminal": {"icon":"","label":"Terminal","title":"Default Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, "setup.default.terminal.foot": {"icon":"","label":"Foot","checked":"[[ \"$(omarchy-default-terminal)\" == \"foot\" ]]","action":"omarchy-default-terminal foot"}, @@ -247,7 +250,6 @@ "install.terminal.kitty": {"icon":"","label":"Kitty","disabled":"omarchy-pkg-present kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, "install.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","disabled":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-chatgpt"}, "install.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","disabled":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-claude"}, - "install.ai.dictation": {"icon":"","label":"Dictation","disabled":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","disabled":"omarchy-pkg-present grok-bot","action":"omarchy-install-and-launch 'Grok Bot' grok-bot grok-bot"}, "install.ai.hermes": {"icon":"","iconFont":"omarchy","label":"Hermes Desktop","disabled":"omarchy-pkg-present hermes-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-ai-hermes"}, "install.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","disabled":"omarchy-pkg-present lmstudio-bin","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, @@ -291,6 +293,7 @@ // Remove "remove.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.omarchy.terminal omarchy-pkg-remove"}, "remove.ai": {"icon":"󱚤","label":"AI","title":"Remove"}, + "remove.dictation": {"icon":"","label":"Dictation","when":"omarchy-pkg-present voxtype-bin || omarchy-pkg-present superwhisper-bin"}, "remove.service": {"icon":"","label":"Services","title":"Remove"}, "remove.development": {"icon":"󰵮","label":"Development","title":"Remove"}, "remove.theme": {"icon":"󰸌","label":"Theme","action":"omarchy-theme-remove"}, @@ -318,7 +321,8 @@ "remove.service.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-service-ssh-agent"}, "remove.ai.chatgpt": {"icon":"","iconFont":"omarchy","label":"ChatGPT Desktop","when":"omarchy-pkg-present openai-codex-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-chatgpt"}, "remove.ai.claude": {"icon":"","iconFont":"omarchy","label":"Claude Desktop","when":"omarchy-pkg-present claude-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-claude"}, - "remove.ai.dictation": {"icon":"","label":"Dictation","when":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-remove"}, + "remove.dictation.voxtype": {"icon":"","label":"Voxtype","when":"omarchy-pkg-present voxtype-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-dictation-voxtype"}, + "remove.dictation.superwhisper": {"icon":"","label":"Superwhisper","when":"omarchy-pkg-present superwhisper-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-dictation-superwhisper"}, "remove.ai.grok-bot": {"icon":"","iconFont":"omarchy","label":"Grok Bot","when":"omarchy-pkg-present grok-bot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-grok-bot"}, "remove.ai.lm-studio": {"icon":"","iconFont":"omarchy","label":"LM Studio","when":"omarchy-pkg-present lmstudio-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-lm-studio"}, "remove.ai.ollama": {"icon":"","iconFont":"omarchy","label":"Ollama","when":"omarchy-pkg-present ollama","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-ai-ollama"}, diff --git a/default/voxtype/config.toml b/default/voxtype/config.toml index ca80e7ee..9ae56db7 100644 --- a/default/voxtype/config.toml +++ b/default/voxtype/config.toml @@ -11,7 +11,7 @@ state_file = "auto" [hotkey] -# Hotkey is configured in Hyprland. Default is Super + Ctrl + X +# Hotkeys are configured in Hyprland: hold Right Alt or F9, or toggle with Super + Ctrl + X. enabled = false [audio] diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index 1d07daa6..d826e42f 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -3,7 +3,7 @@ set -e if omarchy-done ensure voxtype-install-invitation; then - omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" \ + omarchy-notification-send -u critical -g  "Install Dictation" \ "Click to install voice dictation for Omarchy." \ - --exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install + --exec omarchy-menu summon setup.default.dictation fi diff --git a/manual/07-hotkeys.md b/manual/07-hotkeys.md index 5431c8fc..1373d928 100644 --- a/manual/07-hotkeys.md +++ b/manual/07-hotkeys.md @@ -150,8 +150,8 @@ Usually on Linux, you need `Ctrl + Shift + C/V` to copy'n'paste in the terminal | `Super + Alt + ]` | Make webcam overlay larger while recording | | `Alt + Shift + L` | Copy current URL from webapp or Chromium | | `Alt + Shift + D` | Download the video on the current page to `~/Videos` | -| `Super + Ctrl + X` | Start/stop dictation (requires _Install > AI > Dictation_) | -| `F9` | Push-to-talk dictation (requires _Install > AI > Dictation_) | +| `Super + Ctrl + X` | Start/stop dictation (requires _Setup > Defaults > Dictation_) | +| `Right Alt` / `F9` | Push-to-talk dictation (requires Voxtype or Superwhisper) | With screenrecordings, the hotkey first asks which audio you want, then starts recording. Hit it again to stop. See [screenshots and recording](12-screenshots-recording.md) for the details. diff --git a/manual/11-text-extraction-dictation.md b/manual/11-text-extraction-dictation.md index 2d222a67..ab527953 100644 --- a/manual/11-text-extraction-dictation.md +++ b/manual/11-text-extraction-dictation.md @@ -10,6 +10,14 @@ This is very helpful for grabbing addresses out of image footers or phone number ### Dictation -Omarchy offers AI dictation via [Voxtype](https://voxtype.io/). You install it via _Install > AI > Dictation_ through the Omarchy menu. By default, it'll load a base English model that takes up 150MB. But you can tweak which model you'd like to use by running `voxtype setup model` in the terminal. And you can tweak all the settings via `~/.config/voxtype/config.toml`. +Install [Voxtype](https://voxtype.io/) or [Superwhisper](https://superwhisper.com/) through _Setup > Defaults > Dictation_ in the Omarchy menu. Selecting a backend installs it if needed and makes it the default for dictation. Voxtype loads a base English model that takes up 150MB; change the model with `voxtype setup model` and other settings through `~/.config/voxtype/config.toml`. Superwhisper opens its settings to choose cloud or local processing, and OPR keeps the application updated. -Once installed, you dictate by holding down `F9` or by toggling with `Super + Ctrl + X`, and the dictated text will appear in the focused input area. +Once installed, you dictate by holding down `Right Alt` or `F9`, or by toggling with `Super + Ctrl + X`, and the dictated text will appear in the focused input area. These shortcuts are enabled only after selecting a backend. Right Alt also starts dictation when used in a modifier chord; use Left Alt for those chords. On keyboard layouts where Right Alt is AltGr, use `F9` to keep AltGr available for typing. + +The same shortcuts can use an installed Superwhisper instead. Select your backend through Setup > Defaults > Dictation. The backend installer configures it and saves the selection. Run `omarchy default dictation` to see which one is selected. The selection is saved in `~/.config/omarchy/defaults/dictation`. Dictation requires an explicit selection. + +Omarchy loads the selected backend's desktop integration automatically. No Superwhisper configuration is needed in `~/.config/hypr/`. + +Custom bindings and scripts can use `omarchy dictation start`, `omarchy dictation stop`, and `omarchy dictation toggle`. The selected backend handles the recording and transcription. Selecting Superwhisper disables its native hold shortcut and sets its native toggle to `Alt + Space`, keeping it separate from Omarchy's recording keys. Superwhisper requires at least one native recording shortcut, and its cancellation shortcut stays available. + +Additional backends can integrate without changing Omarchy. Provide an executable named `omarchy-dictation-` on PATH that accepts `start`, `stop`, and `toggle` and returns promptly, with a nonzero status on failure. Its installer configures the provider and calls `omarchy-dictation-use ` after successful setup. This stops the previous backend and saves the selection atomically; `stop` must succeed when the provider is idle or unavailable. Backend names use lowercase letters, digits, and hyphens. Provide `omarchy-install-dictation-` for setup; `omarchy default dictation ` launches it. Add a Defaults menu entry through an Omarchy menu extension to call that selector. Optional desktop integration can live in `${XDG_CONFIG_HOME:-$HOME/.config}//shortcuts.lua`. diff --git a/migrations/1791479273.sh b/migrations/1791479273.sh new file mode 100644 index 00000000..c2f38e9f --- /dev/null +++ b/migrations/1791479273.sh @@ -0,0 +1,9 @@ +echo "Preserve Voxtype as the default for existing dictation users" + +backend_config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation" +if [[ ! -e $backend_config ]] && omarchy-pkg-present voxtype-bin; then + mkdir -p "${backend_config%/*}" + temporary=$(mktemp "$backend_config.XXXXXX") + printf '%s\n' voxtype > "$temporary" + mv -f "$temporary" "$backend_config" +fi diff --git a/shell/plugins/bar/indicators/Dictation.qml b/shell/plugins/bar/indicators/Dictation.qml deleted file mode 100644 index 57f3c319..00000000 --- a/shell/plugins/bar/indicators/Dictation.qml +++ /dev/null @@ -1,38 +0,0 @@ -import QtQuick -import Quickshell.Io -import qs.Ui - -BarIndicator { - id: root - - property string state: "idle" - property string icon: "" - - active: state === "recording" - activeText: icon - inactiveText: "󰍬" - activeTooltipText: state - inactiveTooltipText: "Dictate" - - function update(raw) { - var data = extractData(raw) - - state = String(data.alt || data.class || "idle") - if (state === "recording") icon = "󰍬" - else if (state === "transcribing") icon = "󰔟" - else icon = "" - } - - Process { - command: ["bash", "-c", "omarchy-voxtype-status"] - running: true - stdout: SplitParser { - onRead: function(data) { root.update(data) } - } - } - - onPressed: function() { - if (!root.bar) return - root.bar.run("omarchy-voxtype-config") - } -} diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 4296c7f3..9e9ee40b 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -31,11 +31,6 @@ "label": "Passwordless sudo", "description": "Warning while passwordless root access is enabled" }, - { - "value": "Dictation", - "label": "Dictation", - "description": "Voice typing status" - }, { "value": "ScreenRecording", "label": "Screen recording", diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index c2e05cb2..d5679267 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] + readonly property var defaultIndicatorEntries: [ "PasswordlessSudo", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 53c49768..d3988ab7 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -398,6 +398,7 @@ var GUARD_READERS = [ "omarchy-default-browser", "omarchy-default-editor", "omarchy-default-terminal", + "omarchy-default-dictation", "omarchy-dns" ] diff --git a/test/shell.d/dictation-test.sh b/test/shell.d/dictation-test.sh new file mode 100644 index 00000000..5137653c --- /dev/null +++ b/test/shell.d/dictation-test.sh @@ -0,0 +1,318 @@ +#!/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 +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" +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" +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, +} +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") +LUA +pass "dictation shortcuts require a selection without limiting backend names" + +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" diff --git a/test/shell.d/fixtures/indicator-contract/shell.qml b/test/shell.d/fixtures/indicator-contract/shell.qml index 0adc6ade..4b3f04c3 100644 --- a/test/shell.d/fixtures/indicator-contract/shell.qml +++ b/test/shell.d/fixtures/indicator-contract/shell.qml @@ -198,16 +198,6 @@ ShellRoot { root.assertTrue(root.commandCount("omarchy-capture-screenrecording --stop-recording") === 1, "Screen Recording left click stops active recording") } - var dictation = root.createIndicator("Dictation") - if (dictation) { - dictation.moduleName = "Dictation" - root.injectBar(dictation) - dictation.triggerPress(Qt.LeftButton) - dictation.triggerPress(Qt.RightButton) - root.assertTrue(root.commandCount("omarchy-voxtype-config") === 2, "Dictation clicks run config command") - root.assertTrue(root.commandCount("omarchy-voxtype-model") === 0, "Dictation clicks do not run model command") - } - var stayAwake = root.createIndicator("StayAwake") if (stayAwake) { stayAwake.moduleName = "StayAwake" diff --git a/test/shell.d/hyprland-binding-conflicts-test.sh b/test/shell.d/hyprland-binding-conflicts-test.sh index d0eb9e0e..90af5845 100755 --- a/test/shell.d/hyprland-binding-conflicts-test.sh +++ b/test/shell.d/hyprland-binding-conflicts-test.sh @@ -147,8 +147,11 @@ trap 'rm -rf "$tmpdir"' EXIT home="$tmpdir/home" stub_bin="$tmpdir/bin" mkdir -p "$home" "$stub_bin" -touch "$stub_bin/voxtype" -chmod +x "$stub_bin/voxtype" +cat > "$stub_bin/omarchy-default-dictation" <<'SH' +#!/bin/bash +echo voxtype +SH +chmod +x "$stub_bin/omarchy-default-dictation" bindings=$(PATH="$stub_bin:$PATH" list_bindings "$home") [[ -n $bindings ]] || fail "default bindings load for the conflict check" diff --git a/test/shell.d/hyprland-default-config-test.sh b/test/shell.d/hyprland-default-config-test.sh index f50e5761..0e12178a 100644 --- a/test/shell.d/hyprland-default-config-test.sh +++ b/test/shell.d/hyprland-default-config-test.sh @@ -144,36 +144,29 @@ no_bindings_output=$(run_omarchy_bindings "$no_bindings_home" 'omarchy_default_b [[ -z $no_bindings_output ]] || fail "default binding variable disables all Omarchy bindings" "$no_bindings_output" pass "default binding variable disables all Omarchy bindings" -voxtype_home="$tmpdir/voxtype-home" -voxtype_bin="$tmpdir/voxtype-bin" -mkdir -p "$voxtype_home" "$voxtype_bin" -touch "$voxtype_bin/voxtype" -chmod +x "$voxtype_bin/voxtype" -voxtype_output=$(PATH="$voxtype_bin:$PATH" run_omarchy_bindings "$voxtype_home") -grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$voxtype_output" || - fail "installed Voxtype enables its toggle binding" -grep -Fq $'F9 Start dictation (push-to-talk)' <<<"$voxtype_output" || - fail "installed Voxtype enables its push-to-talk binding" -grep -Fq $'F9 Stop dictation (push-to-talk)' <<<"$voxtype_output" || - fail "installed Voxtype enables its release binding" -pass "installed Voxtype conditionally enables dictation bindings" - -voxtype_without_execute_output=$(PATH="$voxtype_bin:$PATH" run_omarchy_bindings \ - "$voxtype_home" 'os.execute = function() return nil, "No child processes", 10 end') -grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$voxtype_without_execute_output" || - fail "Voxtype detection does not require spawning a subprocess" -pass "installed Voxtype detection works without os.execute" - +dictation_home="$tmpdir/dictation-home" missing_bin="$tmpdir/missing-bin" -mkdir -p "$missing_bin" +mkdir -p "$dictation_home" "$missing_bin" ln -s "$(command -v lua)" "$missing_bin/lua" ln -s "$(command -v lspci)" "$missing_bin/lspci" ln -s "$(command -v sort)" "$missing_bin/sort" -missing_voxtype_output=$(PATH="$missing_bin" run_omarchy_bindings "$voxtype_home") -if grep -Fq $'SUPER + CTRL + X Toggle dictation' <<<"$missing_voxtype_output"; then - fail "missing Voxtype skips its bindings" -fi -pass "missing Voxtype skips dictation bindings" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +if grep -Fq 'dictation' <<<"$dictation_output"; then fail "unconfigured dictation leaves application shortcuts available"; fi +cat > "$missing_bin/omarchy-default-dictation" <<'SH' +#!/bin/bash +echo future-backend +SH +chmod +x "$missing_bin/omarchy-default-dictation" +dictation_output=$(PATH="$missing_bin" run_omarchy_bindings "$dictation_home") +for binding in \ + $'SUPER + CTRL + X\tToggle dictation' \ + $'F9\tStart dictation (push-to-talk)' \ + $'F9\tStop dictation (push-to-talk)' \ + $'ALT + Alt_R\tStart dictation (push-to-talk)' \ + $'ALT + Alt_R\tStop dictation (push-to-talk)'; do + grep -Fq "$binding" <<<"$dictation_output" || fail "dictation bindings load for any selected backend" "$binding" +done +pass "dictation bindings require a selection without hardcoding backends" # Lazydocker is optional on new installs, while existing installs keep the hotkey. lazydocker_bin="$tmpdir/lazydocker-bin" diff --git a/test/shell.d/menu-guards-test.sh b/test/shell.d/menu-guards-test.sh index 546596ce..75a7c35a 100755 --- a/test/shell.d/menu-guards-test.sh +++ b/test/shell.d/menu-guards-test.sh @@ -71,7 +71,7 @@ const fs = require('fs') const defaultItems = menu.parseMenuJsonc(fs.readFileSync(path.join(root, 'default/omarchy/omarchy-menu.jsonc'), 'utf8')) const guardText = defaultItems.map(item => `${item.when}\n${item.checked}\n${item.disabled}`).join('\n') const repeated = [...new Set( - (guardText.match(/\$\((omarchy-[a-z0-9-]+)\)/g) || []).map(match => match.slice(2, -1)) + (guardText.match(/\$\((omarchy-[a-z0-9-]+(?: [a-z0-9-]+)*)\)/g) || []).map(match => match.slice(2, -1)) )].filter(command => guardText.split(`$(${command})`).length > 2) assertDeepEqual( repeated.filter(command => !menu.guardReaders.includes(command)), diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index fe0da845..434173b4 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -325,6 +325,7 @@ assertDeepEqual( [ 'remove.package', 'remove.ai', + 'remove.dictation', 'remove.service', 'remove.development', 'remove.theme', @@ -336,7 +337,7 @@ assertDeepEqual( 'remove.preinstalls', 'remove.security' ], - 'menu orders Remove categories like their Install counterparts, followed by Remove-only categories' + 'menu keeps the Remove category order' ) assert( defaultById['setup.security.passwordless-sudo'].action.includes('omarchy-sudo-passwordless'), diff --git a/test/shell.d/passwordless-sudo-indicator-test.sh b/test/shell.d/passwordless-sudo-indicator-test.sh index a52cb915..f9ef1226 100644 --- a/test/shell.d/passwordless-sudo-indicator-test.sh +++ b/test/shell.d/passwordless-sudo-indicator-test.sh @@ -9,7 +9,7 @@ const read = name => fs.readFileSync(path.join(root, name), 'utf8') const indicator = read('shell/plugins/bar/indicators/PasswordlessSudo.qml') const widget = read('shell/plugins/bar/widgets/Indicators.qml') const manifest = JSON.parse(read('shell/plugins/bar/widgets/Indicators.manifest.json')) -assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "Dictation"/), 'passwordless sudo is included in the default indicator tray') +assert(widget.match(/defaultIndicatorEntries: \[ "PasswordlessSudo", "ScreenRecording"/), 'passwordless sudo is included in the default indicator tray') assert(manifest.barWidget.schema.find(field => field.key === 'items').options.some(option => option.value === 'PasswordlessSudo'), 'passwordless sudo is configurable alongside Night Light') assert(indicator.includes('useActiveColor: true') && indicator.includes('activeColor: Commons.Color.urgent'), 'active passwordless sudo uses the theme danger color') assertEqual(indicator.match(/activeText: "([^"]+)"/)[1], indicator.match(/inactiveText: "([^"]+)"/)[1], 'sudo keeps the same icon in both states') diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh index 2c6efd07..84fdc29c 100644 --- a/test/shell.d/voxtype-invitation-test.sh +++ b/test/shell.d/voxtype-invitation-test.sh @@ -45,8 +45,8 @@ run_invitation_hook [[ -f $test_home/.local/state/omarchy/done/voxtype-install-invitation ]] || fail "Voxtype invitation records completion" [[ -f $hook_path ]] || fail "Voxtype invitation keeps its hook installed" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "Voxtype invitation sends one notification" -grep -qx 'exec:omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install' "$log_file" || - fail "Voxtype invitation attaches the installer to the notification" +grep -qx 'exec:omarchy-menu summon setup.default.dictation' "$log_file" || + fail "dictation invitation opens the backend installation choices" grep -q '^systemd-run:' "$log_file" && fail "Voxtype invitation needs no unit to hold an unanswered toast" HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path"