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"