From 804749970ad50e9078db06c1c899ebda6bbe20ca Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 27 Sep 2026 19:02:59 +0200 Subject: [PATCH] Handle volume, media and notification keys in the shell (#13424) * Step the volume in the shell instead of a script per keypress Each volume key ran omarchy-audio-output-volume: several pactl calls in bash, then omarchy-osd delivering the OSD over a qs IPC client, ~165ms from keypress to OSD, on keys that repeat while held. The volume up, down and mute keys now dispatch global shortcuts that the media service handles over PipeWire, stepping, clamping, unmuting and debouncing by the script's rules and showing the same OSD. It acts only when the default sink is an ALSA sink, which is its own physical sink. Any other default, a DSP chain or EasyEffects above all, falls back to the script, which resolves the physical sink from the live routing on every press. The precise +1/-1 keys still run the script. Keypress to OSD drops from ~168ms to ~20ms. Co-Authored-By: Claude Opus 5.5 * Run media and notification keys in the shell without an IPC client The media keys and the notification dismiss, invoke and history keys ran omarchy-shell, starting a qs client for one argument-free IPC call. A new ipc shortcut kind names such a call as target.method. The shell hands it to the service that owns the target, which runs its own IpcHandler function, so the key behaves exactly as the omarchy-shell call did. A call missing from the list binds through omarchy-shell. Dismissing a notification with SUPER+comma clears the popup in ~11ms instead of ~40ms. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- default/hypr/bindings/media.lua | 18 +++---- default/hypr/bindings/utilities.lua | 8 +-- default/hypr/helpers.lua | 5 ++ default/omarchy/shortcuts | 22 ++++++-- shell/plugins/notifications/Service.qml | 9 ++++ shell/plugins/services/media/MediaModel.js | 18 ++++++- shell/plugins/services/media/Service.qml | 58 ++++++++++++++++++++++ shell/shell.qml | 30 ++++++++--- test/shell.d/media-test.sh | 30 +++++++++++ test/shell.d/shell-shortcuts-test.sh | 30 ++++++++++- 10 files changed, 200 insertions(+), 28 deletions(-) diff --git a/default/hypr/bindings/media.lua b/default/hypr/bindings/media.lua index 52779baa..a9961466 100644 --- a/default/hypr/bindings/media.lua +++ b/default/hypr/bindings/media.lua @@ -1,7 +1,7 @@ -- Volume, brightness, keyboard backlight, and touchpad controls. -o.bind("XF86AudioRaiseVolume", "Volume up", "omarchy-audio-output-volume raise", { locked = true, repeating = true }) -o.bind("XF86AudioLowerVolume", "Volume down", "omarchy-audio-output-volume lower", { locked = true, repeating = true }) -o.bind("XF86AudioMute", "Mute", "omarchy-audio-output-volume mute-toggle", { locked = true }) +o.bind("XF86AudioRaiseVolume", "Volume up", { audio = "raise" }, { locked = true, repeating = true }) +o.bind("XF86AudioLowerVolume", "Volume down", { audio = "lower" }, { locked = true, repeating = true }) +o.bind("XF86AudioMute", "Mute", { audio = "mute-toggle" }, { locked = true }) o.bind("XF86AudioMicMute", "Mute microphone", "omarchy-audio-input-mute", { locked = true }) o.bind("XF86MonBrightnessUp", "Brightness up", "omarchy-brightness-display +5%", { locked = true, repeating = true }) o.bind("XF86MonBrightnessDown", "Brightness down", "omarchy-brightness-display 5%-", { locked = true, repeating = true }) @@ -21,12 +21,12 @@ o.bind("ALT + XF86MonBrightnessUp", "Brightness up precise", "omarchy-brightness o.bind("ALT + XF86MonBrightnessDown", "Brightness down precise", "omarchy-brightness-display 1%-", { locked = true, repeating = true }) -- Media controls. -o.bind("XF86AudioNext", "Next track", "omarchy-shell media next", { locked = true }) -o.bind("ALT + XF86AudioPlay", "Next track", "omarchy-shell media next", { locked = true }) -o.bind("XF86AudioPause", "Pause", "omarchy-shell media playPause", { locked = true }) -o.bind("XF86AudioPlay", "Play", "omarchy-shell media playPause", { locked = true }) -o.bind("XF86AudioPrev", "Previous track", "omarchy-shell media previous", { locked = true }) -o.bind("ALT + SHIFT + XF86AudioPlay", "Previous track", "omarchy-shell media previous", { locked = true }) +o.bind("XF86AudioNext", "Next track", { ipc = "media.next" }, { locked = true }) +o.bind("ALT + XF86AudioPlay", "Next track", { ipc = "media.next" }, { locked = true }) +o.bind("XF86AudioPause", "Pause", { ipc = "media.playPause" }, { locked = true }) +o.bind("XF86AudioPlay", "Play", { ipc = "media.playPause" }, { locked = true }) +o.bind("XF86AudioPrev", "Previous track", { ipc = "media.previous" }, { locked = true }) +o.bind("ALT + SHIFT + XF86AudioPlay", "Previous track", { ipc = "media.previous" }, { locked = true }) o.bind("XF86Eject", "Eject media", "eject", { locked = true }) o.bind("SHIFT + XF86AudioMute", "Switch audio output", "omarchy-audio-output-switch", { locked = true }) diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 23066f29..836fa5e5 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -22,11 +22,11 @@ o.bind("SUPER + CTRL + BACKSPACE", "Toggle single-window square aspect", "omarch o.bind_toggle("SUPER + CTRL + ALT + F", "Toggle full screen desktop", "fullscreen-desktop") -- xkbcommon names the comma keysym "comma"; the upper-case "COMMA" does not match. -o.bind("SUPER + comma", "Dismiss last notification", "omarchy-shell notifications dismissOne") -o.bind("SUPER + SHIFT + comma", "Dismiss all notifications", "omarchy-shell notifications dismissAll") +o.bind("SUPER + comma", "Dismiss last notification", { ipc = "notifications.dismissOne" }) +o.bind("SUPER + SHIFT + comma", "Dismiss all notifications", { ipc = "notifications.dismissAll" }) o.bind_toggle("SUPER + CTRL + comma", "Toggle silencing notifications", "notification-silencing") -o.bind("SUPER + ALT + comma", "Invoke last notification", "omarchy-shell notifications invokeLast") -o.bind("SUPER + SHIFT + ALT + comma", "Open notification history", "omarchy-shell notifications showHistory") +o.bind("SUPER + ALT + comma", "Invoke last notification", { ipc = "notifications.invokeLast" }) +o.bind("SUPER + SHIFT + ALT + comma", "Open notification history", { ipc = "notifications.showHistory" }) o.bind_toggle("SUPER + CTRL + I", "Toggle locking on idle", "idle") o.bind_toggle("SUPER + CTRL + N", "Toggle nightlight", "nightlight") diff --git a/default/hypr/helpers.lua b/default/hypr/helpers.lua index ca557f52..eaccf957 100644 --- a/default/hypr/helpers.lua +++ b/default/hypr/helpers.lua @@ -98,6 +98,11 @@ local function command_from(value, description) return shell_dispatcher("menu", value.menu, "omarchy-menu toggle " .. shell_quote(value.menu)) elseif value.panel then return shell_dispatcher("panel", value.panel, "omarchy-shell shell toggle " .. shell_quote(value.panel)) + elseif value.audio then + return shell_dispatcher("audio", value.audio, "omarchy-audio-output-volume " .. shell_quote(value.audio)) + elseif value.ipc then + local target, method = value.ipc:match("^([^.]+)%.(.+)$") + return shell_dispatcher("ipc", value.ipc, "omarchy-shell " .. shell_quote(target) .. " " .. shell_quote(method)) elseif value.focus and value.launch then return o.launch_sole(value.focus, value.launch) elseif value.launch then diff --git a/default/omarchy/shortcuts b/default/omarchy/shortcuts index 3975edc3..27cd0448 100644 --- a/default/omarchy/shortcuts +++ b/default/omarchy/shortcuts @@ -1,9 +1,11 @@ # Hyprland global shortcuts the Omarchy shell registers, one per line: a menu -# route to toggle, or a panel plugin to toggle. A binding written as -# o.bind(keys, description, { menu = "theme" }) or { panel = "omarchy.emojis" } -# dispatches the matching shortcut straight to the shell, with no IPC client to -# spawn per keypress. A route or panel missing here still binds, through the -# omarchy-menu or omarchy-shell command instead. +# route to toggle, a panel plugin to toggle, a volume key, or an argument-free +# IPC call as target.method. A binding written as o.bind(keys, description, +# { menu = "theme" }), { panel = "omarchy.emojis" }, { audio = "raise" } or +# { ipc = "media.next" } dispatches the matching shortcut straight to the shell, +# with no process to spawn per keypress. Anything missing here still binds, +# through the omarchy-menu, omarchy-shell or omarchy-audio-output-volume +# command instead. menu root menu apps menu capture @@ -22,3 +24,13 @@ panel omarchy.monitor panel omarchy.clock panel omarchy.network panel omarchy.power +audio raise +audio lower +audio mute-toggle +ipc media.playPause +ipc media.next +ipc media.previous +ipc notifications.dismissOne +ipc notifications.dismissAll +ipc notifications.invokeLast +ipc notifications.showHistory diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 0122310e..39b8d43b 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -854,7 +854,16 @@ Item { // ---------------------------------------------------- IPC + // Keybindings reach these handlers as Hyprland global shortcuts, run here + // exactly as the IPC call would run them, with no client to spawn. + function runShortcut(method) { + if (typeof ipcHandler[method] !== "function") return false + ipcHandler[method]() + return true + } + IpcHandler { + id: ipcHandler target: "notifications" function dndState(): string { diff --git a/shell/plugins/services/media/MediaModel.js b/shell/plugins/services/media/MediaModel.js index 3edd3a08..c40ccd45 100644 --- a/shell/plugins/services/media/MediaModel.js +++ b/shell/plugins/services/media/MediaModel.js @@ -119,6 +119,20 @@ function osdMessage(player, fallback) { return label || fallback } +// What a volume key does to the output, by omarchy-audio-output-volume's rules: +// raise and lower step 5 and clamp to 0..100 (so a boosted sink drops to 100 +// on raise), unmuting as they go; mute-toggle flips mute and keeps the volume. +function volumeKeyStep(action, percent, muted) { + if (action === "raise") return { percent: Math.min(percent + 5, 100), muted: false } + if (action === "lower") return { percent: Math.max(percent - 5, 0), muted: false } + if (action === "mute-toggle") return { percent: percent, muted: !muted } + return null +} + +function volumeOsdIcon(percent, muted) { + return muted || percent === 0 ? "volume-muted" : "volume-high" +} + if (typeof module !== "undefined") { module.exports = { isProxyPlayer: isProxyPlayer, @@ -137,6 +151,8 @@ if (typeof module !== "undefined") { trackSignature: trackSignature, trackChanged: trackChanged, labelFor: labelFor, - osdMessage: osdMessage + osdMessage: osdMessage, + volumeKeyStep: volumeKeyStep, + volumeOsdIcon: volumeOsdIcon } } diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index 1cfeb9c9..6192a0f9 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -455,6 +455,55 @@ Item { PwObjectTracker { objects: root.playbackStreams } + // ------------------------------------------------------------- volume keys + // + // The volume keys arrive as global shortcuts and change the volume here, + // with no process per press, stepping, clamping, unmuting and debouncing the + // way omarchy-audio-output-volume does, so either path lands on the same + // volume and OSD. That script resolves a DSP sink through to the physical + // sink it feeds on every press, from the live routing. An ALSA sink is its + // own physical sink, so only then do the keys act here; any other default + // sink falls back to the script. + readonly property var defaultSink: Pipewire.defaultAudioSink + readonly property var volumeSink: defaultSink && String(defaultSink.name).indexOf("alsa_output.") === 0 ? defaultSink : null + property double lastMuteToggle: 0 + + // Returns false when the default sink is not one to control here, so the + // caller falls back to the script. + function handleVolumeKey(action) { + var audio = volumeSink && volumeSink.audio + if (!audio) return false + + var step = MediaModel.volumeKeyStep(action, Math.round(audio.volume * 100), audio.muted) + if (!step) return false + + if (action === "mute-toggle") { + // Some keyboards bounce the mute key; the script ignores a second + // toggle within 250ms too. + var now = Date.now() + if (now - lastMuteToggle < 250) return true + lastMuteToggle = now + audio.muted = step.muted + } else { + audio.muted = false + audio.volume = step.percent / 100 + } + + // The payload omarchy-osd builds, from the values just set: the node may + // not report them back before the OSD draws. + shell.summon("omarchy.osd", JSON.stringify({ + icon: MediaModel.volumeOsdIcon(step.percent, step.muted), + message: "", + value: String(step.percent), + progressText: step.percent + "%", + max: "100", + duration: "" + })) + return true + } + + PwObjectTracker { objects: root.defaultSink ? [root.defaultSink] : [] } + function statusJson() { var p = activePlayer return JSON.stringify({ @@ -473,7 +522,16 @@ Item { }) } + // Keybindings reach these handlers as Hyprland global shortcuts, run here + // exactly as the IPC call would run them, with no client to spawn. + function runShortcut(method) { + if (typeof ipcHandler[method] !== "function") return false + ipcHandler[method]() + return true + } + IpcHandler { + id: ipcHandler target: "media" function status(): string { diff --git a/shell/shell.qml b/shell/shell.qml index 21f13e0a..8af1b752 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -1516,9 +1516,9 @@ ShellRoot { // ------------------------------------------------------ global shortcuts // - // Bindings that open a menu route or panel dispatch these through Hyprland, - // so a keypress reaches the shell without spawning an omarchy-shell IPC - // client. The list is shared with default/hypr/helpers.lua, which binds a + // Bindings that open a menu route or panel, or step the volume, dispatch + // these through Hyprland, so a keypress reaches the shell without spawning + // an IPC client or script. The list is shared with default/hypr/helpers.lua, which binds a // route or panel missing from it through the command instead. function parseShortcuts(raw) { @@ -1526,17 +1526,33 @@ ShellRoot { var lines = String(raw || "").split("\n") for (var i = 0; i < lines.length; i++) { var match = /^([A-Za-z]+)\s+(\S+)\s*$/.exec(lines[i]) - if (match && (match[1] === "menu" || match[1] === "panel")) + if (match && ["menu", "panel", "audio", "ipc"].indexOf(match[1]) !== -1) entries.push({ kind: match[1], target: match[2], name: match[1] + "." + match[2] }) } return entries } + // The IPC targets an ipc shortcut may name, and the service that owns each. + readonly property var ipcShortcutServices: ({ media: "omarchy.media", notifications: "omarchy.notifications" }) + function runShortcut(entry) { - if (entry.kind === "menu") + if (entry.kind === "menu") { shell.toggle("omarchy.menu", JSON.stringify({ menu: entry.target })) - else + } else if (entry.kind === "ipc") { + // "media.next" runs the media service's own IPC handler for next. + var dot = entry.target.indexOf(".") + var target = entry.target.slice(0, dot) + var method = entry.target.slice(dot + 1) + var service = shell.serviceFor(shell.ipcShortcutServices[target] || "") + if (!service || !service.runShortcut(method)) + Util.execArgv(["omarchy-shell", target, method]) + } else if (entry.kind === "audio") { + var media = shell.serviceFor("omarchy.media") + if (!media || !media.handleVolumeKey(entry.target)) + Util.execArgv(["omarchy-audio-output-volume", entry.target]) + } else { shell.toggle(entry.target, "{}") + } } FileView { @@ -1554,7 +1570,7 @@ ShellRoot { appid: "omarchy" name: modelData.name - description: modelData.kind === "menu" ? "Toggle the " + modelData.target + " menu" : "Toggle the " + modelData.target + " panel" + description: modelData.kind === "audio" ? "Volume " + modelData.target : (modelData.kind === "ipc" ? "Run " + modelData.target : "Toggle the " + modelData.target + " " + modelData.kind) onPressed: shell.runShortcut(modelData) } } diff --git a/test/shell.d/media-test.sh b/test/shell.d/media-test.sh index e5281306..4019f3a8 100644 --- a/test/shell.d/media-test.sh +++ b/test/shell.d/media-test.sh @@ -40,4 +40,34 @@ assert(media.trackChanged(trackSignature, { ...track, trackTitle: 'Next song' }) assertEqual(media.labelFor({ trackTitle: 'Song', identity: 'Spotify' }), 'Song', 'media labels players by track first') assertEqual(media.osdMessage({ trackTitle: 'Song', trackArtist: 'Artist' }, 'Fallback'), 'Song - Artist', 'media builds OSD messages') assertEqual(media.osdMessage(null, 'Fallback'), 'Fallback', 'media falls back OSD messages') + +// The in-shell volume keys follow omarchy-audio-output-volume's rules. +assertDeepEqual(media.volumeKeyStep('raise', 43, true), { percent: 48, muted: false }, 'volume raise steps 5 and unmutes') +assertDeepEqual(media.volumeKeyStep('lower', 48, true), { percent: 43, muted: false }, 'volume lower steps 5 and unmutes') +assertDeepEqual(media.volumeKeyStep('raise', 98, false), { percent: 100, muted: false }, 'volume raise clamps at 100') +assertDeepEqual(media.volumeKeyStep('raise', 120, false), { percent: 100, muted: false }, 'volume raise brings a boosted sink back to 100') +assertDeepEqual(media.volumeKeyStep('lower', 120, false), { percent: 115, muted: false }, 'volume lower steps down from a boosted sink') +assertDeepEqual(media.volumeKeyStep('lower', 3, false), { percent: 0, muted: false }, 'volume lower clamps at 0') +assertDeepEqual(media.volumeKeyStep('mute-toggle', 48, false), { percent: 48, muted: true }, 'mute toggle keeps the volume') +assertEqual(media.volumeKeyStep('+1', 48, false), null, 'volume keys leave other steps to the script') +assertEqual(media.volumeOsdIcon(48, false), 'volume-high', 'volume OSD shows the speaker when audible') +assertEqual(media.volumeOsdIcon(48, true), 'volume-muted', 'volume OSD shows muted when muted') +assertEqual(media.volumeOsdIcon(0, false), 'volume-muted', 'volume OSD shows muted at zero') + +// Only an ALSA sink is its own physical sink. Any other default sink, a DSP +// chain or EasyEffects above all, needs omarchy-audio-output-sink's live +// resolution on every press, so its keys fall back to the script. +const fs = require('fs') +const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/services/media/Service.qml'), 'utf8') +assert( + serviceQml.includes('readonly property var volumeSink: defaultSink && String(defaultSink.name).indexOf("alsa_output.") === 0 ? defaultSink : null') && + /function handleVolumeKey\(action\) \{\s*var audio = volumeSink && volumeSink\.audio\s*if \(!audio\) return false/.test(serviceQml) && + !serviceQml.includes('volumeSinkName'), + 'volume keys act in the shell only on an ALSA sink and otherwise defer to the script' +) +const shellQml = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +assert( + /if \(!media \|\| !media\.handleVolumeKey\(entry\.target\)\)\s*Util\.execArgv\(\["omarchy-audio-output-volume", entry\.target\]\)/.test(shellQml), + 'a volume key the shell declines runs omarchy-audio-output-volume' +) JS diff --git a/test/shell.d/shell-shortcuts-test.sh b/test/shell.d/shell-shortcuts-test.sh index 17e211e1..d259ece8 100644 --- a/test/shell.d/shell-shortcuts-test.sh +++ b/test/shell.d/shell-shortcuts-test.sh @@ -55,8 +55,12 @@ o.bind("A", "listed menu", { menu = "theme" }) o.bind("B", "unlisted menu", { menu = "setup.power" }) o.bind("C", "listed panel", { panel = "omarchy.emojis" }) o.bind("D", "unlisted panel", { panel = "omarchy.wifiqr" }) +o.bind("E", "listed audio", { audio = "raise" }) +o.bind("F", "unlisted audio", { audio = "+1" }) +o.bind("G", "listed ipc", { ipc = "media.next" }) +o.bind("H", "unlisted ipc", { ipc = "media.sourceNext" }) -for _, file in ipairs({ "utilities", "clipboard" }) do +for _, file in ipairs({ "utilities", "clipboard", "media" }) do dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bindings/" .. file .. ".lua") end LUA @@ -74,6 +78,10 @@ expect_binding $'global\tomarchy:menu.theme\tlisted menu' "a listed menu route b expect_binding $'exec\tomarchy-menu toggle \'setup.power\'\tunlisted menu' "an unlisted menu route falls back to the command" expect_binding $'global\tomarchy:panel.omarchy.emojis\tlisted panel' "a listed panel binds its global shortcut" expect_binding $'exec\tomarchy-shell shell toggle \'omarchy.wifiqr\'\tunlisted panel' "an unlisted panel falls back to the command" +expect_binding $'global\tomarchy:audio.raise\tlisted audio' "a listed volume key binds its global shortcut" +expect_binding $'exec\tomarchy-audio-output-volume \'+1\'\tunlisted audio' "an unlisted volume step falls back to the script" +expect_binding $'global\tomarchy:ipc.media.next\tlisted ipc' "a listed IPC call binds its global shortcut" +expect_binding $'exec\tomarchy-shell \'media\' \'sourceNext\'\tunlisted ipc' "an unlisted IPC call falls back to omarchy-shell" pass "shell bindings use global shortcuts only for what the shell registers" # Every default binding that toggles a listed route or panel goes through its @@ -83,17 +91,35 @@ pass "shell bindings use global shortcuts only for what the shell registers" expect_binding $'global\tomarchy:menu.root\tOmarchy menu' "SUPER+SPACE opens the menu through its shortcut" expect_binding $'global\tomarchy:menu.theme\tTheme menu' "the theme menu binding uses its shortcut" expect_binding $'global\tomarchy:panel.omarchy.clipboard\tClipboard manager' "the clipboard binding uses its shortcut" +expect_binding $'global\tomarchy:audio.raise\tVolume up' "the volume up key steps the volume in the shell" +expect_binding $'global\tomarchy:audio.lower\tVolume down' "the volume down key steps the volume in the shell" +expect_binding $'global\tomarchy:audio.mute-toggle\tMute' "the mute key toggles mute in the shell" +expect_binding $'global\tomarchy:ipc.media.playPause\tPlay' "the play key reaches the media service directly" +expect_binding $'global\tomarchy:ipc.notifications.dismissOne\tDismiss last notification' "dismissing a notification reaches the service directly" +! grep -E $'^exec\tomarchy-shell (media|notifications) ' <<<"$bindings" || + fail "default media and notification keys reach their services through global shortcuts" pass "default bindings toggle menus and panels through global shortcuts" # The shell and the helpers read the same list, in the same format. while IFS= read -r line; do [[ -z $line || $line == \#* ]] && continue - [[ $line =~ ^(menu|panel)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line" + [[ $line =~ ^(menu|panel|audio|ipc)\ [^[:space:]]+$ ]] || fail "shortcuts lines are a kind and a target: $line" + if [[ $line == ipc\ * ]]; then + [[ $line =~ ^ipc\ (media|notifications)\.[A-Za-z]+$ ]] || fail "ipc shortcuts name a mapped target and method: $line" + fi done <"$shortcuts" grep -q 'path: shell.omarchyPath + "/default/omarchy/shortcuts"' "$ROOT/shell/shell.qml" || fail "the shell registers the shortcuts the helpers bind" grep -q 'paths.omarchy_path .. "/default/omarchy/shortcuts"' "$ROOT/default/hypr/helpers.lua" || fail "the helpers bind the shortcuts the shell registers" +# An ipc shortcut runs the service's own IPC handler, so it behaves exactly as +# the omarchy-shell call it replaces. +for service in services/media notifications; do + grep -Pzq 'function runShortcut\(method\) \{\n if \(typeof ipcHandler\[method\] !== "function"\) return false\n ipcHandler\[method\]\(\)' "$ROOT/shell/plugins/$service/Service.qml" || + fail "$service runs ipc shortcuts through its IPC handler" + grep -Pzq 'IpcHandler \{\n id: ipcHandler' "$ROOT/shell/plugins/$service/Service.qml" || + fail "$service names its IPC handler for shortcuts" +done pass "the shell and the helpers share one shortcut list" # Every default menu binding target is a real menu route.