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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 authored and GitHub committed 2026-09-27 20:46:00 +02:00
1 parent 616feda103
commit c231097df7
27 files changed
+356 -23

No files matched your search

+77
View File
@@ -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) }
}
}
+12
View File
@@ -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)
}
+2
View File
@@ -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
+1 -1
View File
@@ -45,7 +45,7 @@ Item {
id: panelController
}
IpcHandler {
ShellIpc {
enabled: root.manageIpc && root.ipcTarget !== ""
target: root.ipcTarget
+1 -1
View File
@@ -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() }
+1 -1
View File
@@ -208,7 +208,7 @@ Item {
}
}
IpcHandler {
ShellIpc {
target: "background"
function refresh(): void {
+1 -1
View File
@@ -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
+1 -1
View File
@@ -165,7 +165,7 @@ BarWidget {
? activeVerticalBlock.implicitHeight + inactiveVerticalArea.implicitHeight
: Math.max(activeHorizontalBlock.implicitHeight, inactiveHorizontalArea.implicitHeight)
IpcHandler {
ShellIpc {
target: "omarchy.indicators"
function refresh(): void {
+1 -1
View File
@@ -24,7 +24,7 @@ BarWidget {
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
IpcHandler {
ShellIpc {
target: "omarchy.system-update"
function refresh(): void {
+1 -1
View File
@@ -641,7 +641,7 @@ Item {
checkStrandedLock()
}
IpcHandler {
ShellIpc {
target: "lock"
function lock(): string {
+1 -1
View File
@@ -862,7 +862,7 @@ Item {
return true
}
IpcHandler {
ShellIpc {
id: ipcHandler
target: "notifications"
+1 -1
View File
@@ -112,7 +112,7 @@ Item {
text: OsdModel.widestIcon
}
IpcHandler {
ShellIpc {
target: "osd"
function show(payloadJson: string): string {
root.open(payloadJson)
+1 -1
View File
@@ -637,7 +637,7 @@ Panel {
Quickshell.execDetached(["omarchy-bluetooth-power", adapter.enabled ? "off" : "on"])
}
IpcHandler {
ShellIpc {
target: "omarchy.bluetooth"
function open() { root.open() }
+1 -1
View File
@@ -130,7 +130,7 @@ BarWidget {
}
}
IpcHandler {
ShellIpc {
target: "omarchy.clock"
function refresh(): void { root.broadcast("refresh") }
+1 -1
View File
@@ -155,7 +155,7 @@ Panel {
function onFilesChanged() { root.ensureCursor() }
}
IpcHandler {
ShellIpc {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
+1 -1
View File
@@ -217,7 +217,7 @@ Panel {
})
}
IpcHandler {
ShellIpc {
target: "omarchy.monitor"
function brightness(percent: string): string { return root.brightnessIpc(percent) }
+1 -1
View File
@@ -207,7 +207,7 @@ Panel {
Qt.callLater(function() { root.refresh(true) })
}
IpcHandler {
ShellIpc {
target: "omarchy.network"
function open() { root.open() }
+1 -1
View File
@@ -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() }
+1 -1
View File
@@ -363,7 +363,7 @@ Panel {
function onAccountsAccessDeniedChanged() { root.ensureCursor() }
}
IpcHandler {
ShellIpc {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
+1 -1
View File
@@ -475,7 +475,7 @@ Panel {
onTriggered: root.refresh()
}
IpcHandler {
ShellIpc {
target: root.ipcTarget
function open(): void { root.openFromHotkey() }
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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"
@@ -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 {
+36 -2
View File
@@ -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 {