From c231097df7443b3fe990d6ed6e9f90b963260430 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 27 Sep 2026 20:46:00 +0200 Subject: [PATCH] Answer omarchy-shell calls over the shell's own socket (#13435) * Answer omarchy-shell calls over the shell's own socket Every omarchy-shell call started a qs ipc client, ~45ms of startup for one IPC call: a theme switch makes two, and every script-driven OSD, toggle refresh and lock query paid it too. The shell now serves a socket in XDG_RUNTIME_DIR, named from its config path and Wayland display as qs ipc selects its instance, and omarchy-shell tries it first through socat, which starts in ~5ms. First-party handlers register as ShellIpc, an IpcHandler that qs ipc still reaches, and the socket calls only the functions a handler declares with their exact argument count, allowed by name so QObject methods such as destroy() stay out of reach. When the shell ran nothing it answers SKIP, and omarchy-shell asks qs ipc for its exact answer, so errors, third-party plugins and an unreachable socket behave as before. A call that may have run is never retried: a timeout or a connection closed without an answer reports the shell as not responding. omarchy-shell shell ping takes ~13-18ms instead of ~61ms, and omarchy-osd reaches the screen in ~36ms instead of ~77ms. Output and exit status match the qs ipc path across 26 calls, errors and quiet mode included. Co-Authored-By: Claude Opus 5.5 * Only accept whole socket replies and retry only unmade connections A reply cut off after its OK prefix passed for the whole answer, and an empty reply with socat failing was retried through qs ipc although the request might already have been delivered. An answer now counts only once its record separator arrived. socat's own errors join the reply, so only its connect error, a socket nothing listens on, falls back to qs ipc beside an explicit SKIP; anything else is reported as not responding rather than retried. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- bin/omarchy-shell | 44 +++++ shell/Commons/IpcRegistry.qml | 77 +++++++++ shell/Commons/ShellIpc.qml | 12 ++ shell/Commons/qmldir | 2 + shell/Ui/Panel.qml | 2 +- shell/plugins/agents/Panel.qml | 2 +- shell/plugins/background/Background.qml | 2 +- shell/plugins/bar/Bar.qml | 2 +- shell/plugins/bar/widgets/Indicators.qml | 2 +- shell/plugins/bar/widgets/SystemUpdate.qml | 2 +- shell/plugins/lock/Service.qml | 2 +- shell/plugins/notifications/Service.qml | 2 +- shell/plugins/osd/Osd.qml | 2 +- shell/plugins/panels/bluetooth/Panel.qml | 2 +- shell/plugins/panels/clock/BarWidget.qml | 2 +- shell/plugins/panels/dropbox/Panel.qml | 2 +- shell/plugins/panels/monitor/Panel.qml | 2 +- shell/plugins/panels/network/Panel.qml | 2 +- shell/plugins/panels/power/Panel.qml | 2 +- shell/plugins/panels/tailscale/Panel.qml | 2 +- shell/plugins/panels/weather/Panel.qml | 2 +- shell/plugins/services/idle/Service.qml | 3 +- shell/plugins/services/media/Service.qml | 3 +- shell/plugins/services/nightlight/Service.qml | 3 +- shell/shell.qml | 38 ++++- test/shell.d/shell-ipc-socket-test.sh | 161 ++++++++++++++++++ test/shell.d/shell-shortcuts-test.sh | 2 +- 27 files changed, 356 insertions(+), 23 deletions(-) create mode 100644 shell/Commons/IpcRegistry.qml create mode 100644 shell/Commons/ShellIpc.qml create mode 100644 test/shell.d/shell-ipc-socket-test.sh diff --git a/bin/omarchy-shell b/bin/omarchy-shell index ac8b3108..8494c407 100755 --- a/bin/omarchy-shell +++ b/bin/omarchy-shell @@ -52,6 +52,50 @@ if [[ $1 == "shell" && ( $2 == "summon" || $2 == "toggle" ) ]] && (( $# == 3 )); set -- "$1" "$2" "$3" "{}" fi +# The shell answers on its own socket first: socat starts in ~5ms where a qs +# ipc client takes ~45ms. Fields are split by unit separators and the request +# ends with a record separator, so an argument holding either goes through qs +# ipc instead. The shell replies "OK" with the output once the call has run, +# or "SKIP" when it ran nothing (no such target or function, the wrong number +# of arguments), and qs ipc then gives its exact answer. A timeout after the +# request is out is reported, not retried: the call may already have run. +# Like qs ipc, the socket belongs to one shell, the one running this config on +# this display, and the shell derives its name from the same two values. +socket_id=$(printf '%s\n%s' "$OMARCHY_PATH/shell" "${WAYLAND_DISPLAY:-}" | md5sum) +socket="${XDG_RUNTIME_DIR:-/run/user/$UID}/omarchy-shell-${socket_id:0:16}.sock" +request="$*" +if [[ -S $socket && $request != *$'\x1e'* && $request != *$'\x1f'* ]]; then + saved_ifs=$IFS + IFS=$'\x1f' + request="$*" + IFS=$saved_ifs + # socat waits as long as the shell takes; the timeout alone bounds the call. + # Its own errors land in the reply too, so a connection it could not make + # is told apart from one that failed after the request went out. + reply=$(printf '%s\x1e' "$request" | timeout --kill-after=1s "${OMARCHY_SHELL_IPC_TIMEOUT:-2s}" socat -t 86400 - "UNIX-CONNECT:$socket" 2>&1) + + # An answer counts only once its record separator arrived: a reply cut off + # partway must not pass for the whole of it. + if [[ $reply == OK$'\x1f'*$'\x1e'* ]]; then + output=${reply#OK$'\x1f'} + output=${output%$'\x1e'*} + # Match qs ipc's path, whose $(...) drops trailing newlines. + while [[ $output == *$'\n' ]]; do output=${output%$'\n'}; done + if (( !QUIET )) && [[ -n $output ]]; then + echo "$output" + fi + exit 0 + elif [[ $reply == "SKIP"$'\x1e'* ]]; then + : # The shell ran nothing; qs ipc gives its exact answer below. + elif [[ $reply != OK* && $reply == *" E connect("* ]]; then + : # socat never connected, a stale socket; qs ipc answers below. + else + # Out of time, cut off, or failed after the request went out: the call + # may have run, so it is reported rather than retried. + fail "omarchy-shell is not responding" + fi +fi + # The -- keeps function names that shadow qs subcommands (e.g. show) as # positionals. qs reports connection failures with a nonzero exit, but IPC-level # failures (unknown target/function, bad arguments) go to stdout with exit 0. diff --git a/shell/Commons/IpcRegistry.qml b/shell/Commons/IpcRegistry.qml new file mode 100644 index 00000000..e479df28 --- /dev/null +++ b/shell/Commons/IpcRegistry.qml @@ -0,0 +1,77 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +// Every ShellIpc handler, so the shell can answer an omarchy-shell call over +// its own socket instead of through a qs ipc client started per call. It +// answers only what qs ipc would: the first live, enabled handler for a +// target, and only the functions that handler declares. Those are allowed by +// name, never inferred from what a function call happens to reach: QObject +// methods such as destroy() are callable but not enumerable. +Singleton { + id: root + + property var handlers: [] + + // A bare handler's own functions (signals and hooks such as targetChanged), + // which qs ipc never exposes and neither may the socket. + readonly property var builtins: functionNames(bareHandler) + + IpcHandler { + id: bareHandler + enabled: false + } + + function functionNames(object) { + var names = [] + for (var key in object) { + if (typeof object[key] === "function") names.push(key) + } + return names + } + + function register(handler) { + if (handlers.indexOf(handler) === -1) handlers.push(handler) + } + + function unregister(handler) { + var index = handlers.indexOf(handler) + if (index !== -1) handlers.splice(index, 1) + } + + function handlerFor(target) { + for (var i = 0; i < handlers.length; i++) { + var handler = handlers[i] + if (handler && handler.enabled && handler.target === target) return handler + } + return null + } + + // { ran: true, output } once the function ran; { ran: false } when it did + // not (unknown target or function, wrong argument count), so the caller + // can ask qs ipc for its exact answer without running anything twice. + // The functions a handler declares: what it enumerates beyond a bare + // handler, less property change signals. + function declaredFunctions(handler) { + return functionNames(handler).filter(function(name) { + return builtins.indexOf(name) === -1 && !/Changed$/.test(name) + }) + } + + function call(target, method, args) { + var handler = handlerFor(target) + if (!handler) return { ran: false } + if (declaredFunctions(handler).indexOf(method) === -1) return { ran: false } + if (handler[method].length !== args.length) return { ran: false } + + var result + try { + result = handler[method].apply(handler, args) + } catch (error) { + console.warn("ipc " + target + " " + method + " failed: " + error) + return { ran: true, output: "" } + } + return { ran: true, output: result === undefined || result === null ? "" : String(result) } + } +} diff --git a/shell/Commons/ShellIpc.qml b/shell/Commons/ShellIpc.qml new file mode 100644 index 00000000..15f5d27e --- /dev/null +++ b/shell/Commons/ShellIpc.qml @@ -0,0 +1,12 @@ +import QtQuick +import Quickshell.Io + +// An IpcHandler the shell can also answer over its own socket (see +// IpcRegistry), so omarchy-shell reaches it without a qs ipc client. qs ipc +// still reaches it as before. +IpcHandler { + id: handler + + Component.onCompleted: IpcRegistry.register(handler) + Component.onDestruction: IpcRegistry.unregister(handler) +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 4bfd0b78..1c9101c1 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -1,5 +1,7 @@ module qs.Commons singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml +singleton IpcRegistry 1.0 IpcRegistry.qml singleton Style 1.0 Style.qml singleton Util 1.0 Util.qml +ShellIpc 1.0 ShellIpc.qml diff --git a/shell/Ui/Panel.qml b/shell/Ui/Panel.qml index b8866b5b..371d2c6e 100644 --- a/shell/Ui/Panel.qml +++ b/shell/Ui/Panel.qml @@ -45,7 +45,7 @@ Item { id: panelController } - IpcHandler { + ShellIpc { enabled: root.manageIpc && root.ipcTarget !== "" target: root.ipcTarget diff --git a/shell/plugins/agents/Panel.qml b/shell/plugins/agents/Panel.qml index f4ecdd9a..fa676a6a 100644 --- a/shell/plugins/agents/Panel.qml +++ b/shell/plugins/agents/Panel.qml @@ -324,7 +324,7 @@ Panel { onTriggered: root.nowMs = Date.now() } - IpcHandler { + ShellIpc { target: root.ipcTarget function open(): void { root.open() } function close(): void { root.close() } diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 958eb90b..7a078858 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -208,7 +208,7 @@ Item { } } - IpcHandler { + ShellIpc { target: "background" function refresh(): void { diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index be9fece4..ffd33a85 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1182,7 +1182,7 @@ Item { // changes land in quick succession, stranding the bar off screen until the // shell restarts. `omarchy-toggle-bar` nudges this after flipping the flag // so the probe re-reads it even when the watch has gone quiet. - IpcHandler { + ShellIpc { target: "omarchy.bar" // Start rather than restart: a probe already in flight was launched by the diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index cb35c5a2..00417eed 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -165,7 +165,7 @@ BarWidget { ? activeVerticalBlock.implicitHeight + inactiveVerticalArea.implicitHeight : Math.max(activeHorizontalBlock.implicitHeight, inactiveHorizontalArea.implicitHeight) - IpcHandler { + ShellIpc { target: "omarchy.indicators" function refresh(): void { diff --git a/shell/plugins/bar/widgets/SystemUpdate.qml b/shell/plugins/bar/widgets/SystemUpdate.qml index a4f9020f..2baf6b0d 100644 --- a/shell/plugins/bar/widgets/SystemUpdate.qml +++ b/shell/plugins/bar/widgets/SystemUpdate.qml @@ -24,7 +24,7 @@ BarWidget { implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight - IpcHandler { + ShellIpc { target: "omarchy.system-update" function refresh(): void { diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 247495a4..845e1d1e 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -641,7 +641,7 @@ Item { checkStrandedLock() } - IpcHandler { + ShellIpc { target: "lock" function lock(): string { diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index 39b8d43b..25f5feff 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -862,7 +862,7 @@ Item { return true } - IpcHandler { + ShellIpc { id: ipcHandler target: "notifications" diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index a0186880..0daf676a 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -112,7 +112,7 @@ Item { text: OsdModel.widestIcon } - IpcHandler { + ShellIpc { target: "osd" function show(payloadJson: string): string { root.open(payloadJson) diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index b0078f84..5362d36b 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -637,7 +637,7 @@ Panel { Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"]) } - IpcHandler { + ShellIpc { target: "omarchy.bluetooth" function open() { root.open() } diff --git a/shell/plugins/panels/clock/BarWidget.qml b/shell/plugins/panels/clock/BarWidget.qml index 61cf0bf5..3235a1cc 100644 --- a/shell/plugins/panels/clock/BarWidget.qml +++ b/shell/plugins/panels/clock/BarWidget.qml @@ -130,7 +130,7 @@ BarWidget { } } - IpcHandler { + ShellIpc { target: "omarchy.clock" function refresh(): void { root.broadcast("refresh") } diff --git a/shell/plugins/panels/dropbox/Panel.qml b/shell/plugins/panels/dropbox/Panel.qml index 515b43ce..9dec2473 100644 --- a/shell/plugins/panels/dropbox/Panel.qml +++ b/shell/plugins/panels/dropbox/Panel.qml @@ -155,7 +155,7 @@ Panel { function onFilesChanged() { root.ensureCursor() } } - IpcHandler { + ShellIpc { target: root.ipcTarget function open(): void { root.open() } function close(): void { root.close() } diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index bec38820..44f73f4a 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -217,7 +217,7 @@ Panel { }) } - IpcHandler { + ShellIpc { target: "omarchy.monitor" function brightness(percent: string): string { return root.brightnessIpc(percent) } diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 97729dcc..5976a485 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -207,7 +207,7 @@ Panel { Qt.callLater(function() { root.refresh(true) }) } - IpcHandler { + ShellIpc { target: "omarchy.network" function open() { root.open() } diff --git a/shell/plugins/panels/power/Panel.qml b/shell/plugins/panels/power/Panel.qml index b1c34da2..9e251fc4 100644 --- a/shell/plugins/panels/power/Panel.qml +++ b/shell/plugins/panels/power/Panel.qml @@ -174,7 +174,7 @@ Panel { if (root.bar && root.bar.shell) root.bar.shell.updateEntryInline(root.moduleName, root.settings) } - IpcHandler { + ShellIpc { target: "omarchy.power" function open() { root.open() } diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 6a976307..9d9c7021 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -363,7 +363,7 @@ Panel { function onAccountsAccessDeniedChanged() { root.ensureCursor() } } - IpcHandler { + ShellIpc { target: root.ipcTarget function open(): void { root.open() } function close(): void { root.close() } diff --git a/shell/plugins/panels/weather/Panel.qml b/shell/plugins/panels/weather/Panel.qml index d9d43b68..c237e223 100644 --- a/shell/plugins/panels/weather/Panel.qml +++ b/shell/plugins/panels/weather/Panel.qml @@ -475,7 +475,7 @@ Panel { onTriggered: root.refresh() } - IpcHandler { + ShellIpc { target: root.ipcTarget function open(): void { root.openFromHotkey() } diff --git a/shell/plugins/services/idle/Service.qml b/shell/plugins/services/idle/Service.qml index 1453920d..19dab2a2 100644 --- a/shell/plugins/services/idle/Service.qml +++ b/shell/plugins/services/idle/Service.qml @@ -4,6 +4,7 @@ import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import "IdleModel.js" as IdleModel +import qs.Commons Item { id: root @@ -335,7 +336,7 @@ Item { refreshStayAwakeState() } - IpcHandler { + ShellIpc { target: "idle" function status(): string { diff --git a/shell/plugins/services/media/Service.qml b/shell/plugins/services/media/Service.qml index 6192a0f9..0653627c 100644 --- a/shell/plugins/services/media/Service.qml +++ b/shell/plugins/services/media/Service.qml @@ -4,6 +4,7 @@ import Quickshell.Io import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import "MediaModel.js" as MediaModel +import qs.Commons Item { id: root @@ -530,7 +531,7 @@ Item { return true } - IpcHandler { + ShellIpc { id: ipcHandler target: "media" diff --git a/shell/plugins/services/nightlight/Service.qml b/shell/plugins/services/nightlight/Service.qml index 388b7798..18ed7d16 100644 --- a/shell/plugins/services/nightlight/Service.qml +++ b/shell/plugins/services/nightlight/Service.qml @@ -1,6 +1,7 @@ import QtQuick import Quickshell.Io import "NightlightModel.js" as NightlightModel +import qs.Commons Item { id: root @@ -85,7 +86,7 @@ Item { Component.onCompleted: refresh() - IpcHandler { + ShellIpc { target: "nightlight" function status(): string { diff --git a/shell/shell.qml b/shell/shell.qml index e1ab62f9..fee6a3b3 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -1579,6 +1579,40 @@ ShellRoot { } } + // ------------------------------------------------------------ IPC socket + // + // omarchy-shell reaches the shell here first: a qs ipc client costs ~45ms + // to start per call, socat ~5ms. A request is target, method and arguments + // separated by unit separators and ended by a record separator. The reply is + // "OK" and the output, or "SKIP" when nothing ran (no such target or + // function, or the wrong number of arguments), which omarchy-shell hands to + // qs ipc for its exact answer. The socket sits in XDG_RUNTIME_DIR, private + // to the user like qs ipc's own. Like qs ipc, it belongs to one shell: the + // one running this config on this display. omarchy-shell derives the same + // name from the same two values. + readonly property string ipcSocketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/omarchy-shell-" + + Qt.md5(shell.omarchyPath + "/shell\n" + Quickshell.env("WAYLAND_DISPLAY")).slice(0, 16) + ".sock" + + SocketServer { + active: shell.omarchyPath !== "" + path: shell.ipcSocketPath + + handler: Socket { + id: connection + + parser: SplitParser { + splitMarker: "\u001e" + onRead: function(data) { + var fields = String(data).split("\u001f") + var result = fields.length >= 2 ? IpcRegistry.call(fields[0], fields[1], fields.slice(2)) : { ran: false } + connection.write(result.ran ? "OK\u001f" + result.output + "\u001e" : "SKIP\u001e") + connection.flush() + connection.connected = false + } + } + } + } + // --------------------------------------------------- image selector IPC function imagePickerItem() { @@ -1586,7 +1620,7 @@ ShellRoot { return loader && loader.item ? loader.item : null } - IpcHandler { + ShellIpc { target: "image-selector" function open(imageDirs: string, @@ -1637,7 +1671,7 @@ ShellRoot { // ---------------------------------------------------------- shell IPC - IpcHandler { + ShellIpc { target: "shell" function ping(): string { diff --git a/test/shell.d/shell-ipc-socket-test.sh b/test/shell.d/shell-ipc-socket-test.sh new file mode 100644 index 00000000..5e3877a0 --- /dev/null +++ b/test/shell.d/shell-ipc-socket-test.sh @@ -0,0 +1,161 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +require_command socat + +test_tmp=$(mktemp -d) +server_pid="" +cleanup() { + [[ -n $server_pid ]] && kill "$server_pid" 2>/dev/null || true + rm -rf "$test_tmp" +} +trap cleanup EXIT + +root_dir="$test_tmp/root" +run_dir="$test_tmp/run" +stub_bin="$test_tmp/bin" +mkdir -p "$root_dir/shell" "$run_dir" "$stub_bin" +touch "$root_dir/shell/shell.qml" +# The shell names its socket after its config and display, as omarchy-shell +# derives it. +display="test-display" +socket_id=$(printf '%s\n%s' "$root_dir/shell" "$display" | md5sum) +socket="$run_dir/omarchy-shell-${socket_id:0:16}.sock" + +# qs is the fallback: it records that it ran and answers. +cat >"$stub_bin/qs" <<'SH' +#!/bin/bash +printf 'qs\n' >>"$QS_CALLS" +printf 'from-qs\n' +SH +chmod +x "$stub_bin/qs" + +# The fake shell replies as the mode file says, and logs each request with +# its separators made visible. +cat >"$test_tmp/responder" <<'SH' +#!/bin/bash +IFS= read -r -d $'\x1e' request +printf '%s\n' "${request//$'\x1f'/|}" >>"$REQUESTS" +case $(<"$MODE") in + ok) printf 'OK\x1f%s\x1e' "answer to ${request//$'\x1f'/ }" ;; + newlines) printf 'OK\x1fline\n\n\x1e' ;; + skip) printf 'SKIP\x1e' ;; + hang) sleep 2 ;; + close) ;; + partial) printf 'OK\x1fpartial answer' ;; +esac +SH +chmod +x "$test_tmp/responder" + +mode="$test_tmp/mode" +calls="$test_tmp/qs-calls" +requests="$test_tmp/requests" +MODE="$mode" REQUESTS="$requests" socat UNIX-LISTEN:"$socket",fork SYSTEM:"$test_tmp/responder" 2>/dev/null & +server_pid=$! +for _ in $(seq 50); do [[ -S $socket ]] && break; sleep 0.05; done + +shell_call() { + : >"$calls" + PATH="$stub_bin:$PATH" OMARCHY_PATH="$root_dir" XDG_RUNTIME_DIR="$run_dir" WAYLAND_DISPLAY="$display" QS_CALLS="$calls" \ + OMARCHY_SHELL_IPC_TIMEOUT="${IPC_TIMEOUT:-2s}" "$ROOT/bin/omarchy-shell" "$@" +} + +printf 'ok' >"$mode" +output=$(shell_call media next extra-arg) +[[ $output == "answer to media next extra-arg" ]] || fail "the shell answers over its socket" "got: $output" +[[ ! -s $calls ]] || fail "an answered call never starts qs ipc" +[[ $(tail -n1 "$requests") == "media|next|extra-arg" ]] || fail "arguments travel as separate fields" "got: $(tail -n1 "$requests")" +pass "the shell answers over its socket without starting qs ipc" + +output=$(shell_call -q media next) +[[ -z $output ]] || fail "quiet calls print nothing" "got: $output" +pass "quiet calls over the socket print nothing" + +printf 'newlines' >"$mode" +output=$(shell_call shell ping) +[[ $output == "line" ]] || fail "socket output drops trailing newlines as qs ipc's does" "got: $(printf '%q' "$output")" +pass "socket output drops trailing newlines as the qs ipc path does" + +printf 'skip' >"$mode" +output=$(shell_call nosuch ping) +[[ $output == "from-qs" && -s $calls ]] || fail "a call the shell did not run goes to qs ipc" "got: $output" +pass "a call the shell did not run goes to qs ipc for its exact answer" + +printf 'ok' >"$mode" +: >"$requests" +output=$(shell_call shell summon "$(printf 'a\x1fb')") +[[ $output == "from-qs" && ! -s $requests ]] || fail "an argument holding a separator goes straight to qs ipc" "got: $output" +pass "an argument holding a separator goes straight to qs ipc" + +printf 'hang' >"$mode" +output=$(IPC_TIMEOUT=0.5s shell_call shell ping 2>&1) && fail "a shell that never replies fails the call" +[[ $output == "omarchy-shell is not responding" && ! -s $calls ]] || + fail "a call that may have run is reported, not retried through qs ipc" "got: $output" +pass "a call that may have run is reported as unresponsive, not retried" + +printf 'close' >"$mode" +output=$(shell_call shell ping 2>&1) && fail "a shell that closes without answering fails the call" +[[ $output == "omarchy-shell is not responding" && ! -s $calls ]] || + fail "a connection closed without an answer is reported, not retried" "got: $output" +pass "a connection closed without an answer is reported, not retried" + +printf 'partial' >"$mode" +output=$(shell_call shell listPlugins 2>&1) && fail "a reply cut off before its end fails the call" +[[ $output == "omarchy-shell is not responding" && ! -s $calls ]] || + fail "a reply cut off before its end is reported, not taken or retried" "got: $output" +pass "a reply cut off before its end is reported, not taken or retried" + +# SIGKILL leaves the socket file behind with nothing listening, as a crashed +# shell does; socat then never connects, so qs ipc may safely answer. +kill -9 "$server_pid" +wait "$server_pid" 2>/dev/null || true +server_pid="" +[[ -S $socket ]] || fail "a killed listener leaves its socket file behind" +output=$(shell_call shell ping) +[[ $output == "from-qs" ]] || fail "a stale socket with nothing listening falls back to qs ipc" "got: $output" +pass "a stale socket with nothing listening falls back to qs ipc" + +rm -f "$socket" +output=$(shell_call shell ping) +[[ $output == "from-qs" ]] || fail "without the socket calls go through qs ipc" "got: $output" +pass "without the socket calls go through qs ipc" + +# A call for a different display never reaches this shell's socket. +printf 'ok' >"$mode" +MODE="$mode" REQUESTS="$requests" socat UNIX-LISTEN:"$socket",fork SYSTEM:"$test_tmp/responder" 2>/dev/null & +server_pid=$! +for _ in $(seq 50); do [[ -S $socket ]] && break; sleep 0.05; done +output=$(display="other-display" shell_call shell ping) +[[ $output == "from-qs" ]] || fail "a call for another display does not reach this shell's socket" "got: $output" +pass "a call for another display does not reach this shell's socket" + +run_node_test <<'JS' +const fs = require('fs') +const { execSync } = require('child_process') +const shellQml = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +const registry = fs.readFileSync(path.join(root, 'shell/Commons/IpcRegistry.qml'), 'utf8') + +// Only handlers that register can answer over the socket, so every +// first-party one does; the registry's own bare handler is the reference for +// the functions a handler never exposes. +const bare = execSync(`grep -rln 'IpcHandler {' ${path.join(root, 'shell')} --include=*.qml || true`).toString().trim().split('\n').filter(Boolean) +assertDeepEqual(bare.map((f) => path.relative(root, f)).sort(), ['shell/Commons/IpcRegistry.qml', 'shell/Commons/ShellIpc.qml'], 'first-party IPC handlers register through ShellIpc') + +// Declared functions are allowed by name: destroy() and other QObject +// methods are callable yet not enumerable, so subtracting builtins from what a +// call can reach would let them through. +assert( + registry.includes('readonly property var builtins: functionNames(bareHandler)') && + /return builtins\.indexOf\(name\) === -1 && !\/Changed\$\/\.test\(name\)/.test(registry) && + registry.includes('if (declaredFunctions(handler).indexOf(method) === -1) return { ran: false }') && + registry.includes('handler[method].length !== args.length'), + 'the socket answers only declared functions with their exact argument count' +) +assert( + shellQml.includes('+ Qt.md5(shell.omarchyPath + "/shell\\n" + Quickshell.env("WAYLAND_DISPLAY")).slice(0, 16) + ".sock"'), + 'the shell names its socket after its config and display, as omarchy-shell does' +) +JS diff --git a/test/shell.d/shell-shortcuts-test.sh b/test/shell.d/shell-shortcuts-test.sh index b8a6dc3f..e79d1d46 100644 --- a/test/shell.d/shell-shortcuts-test.sh +++ b/test/shell.d/shell-shortcuts-test.sh @@ -121,7 +121,7 @@ grep -q 'paths.omarchy_path .. "/default/omarchy/shortcuts"' "$ROOT/default/hypr 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" || + grep -Pzq '(IpcHandler|ShellIpc) \{\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"