diff --git a/bin/omarchy-bar b/bin/omarchy-bar index 1ae63f3c..2a29c634 100755 --- a/bin/omarchy-bar +++ b/bin/omarchy-bar @@ -35,8 +35,9 @@ Placement: Enable and disable widgets with 'omarchy plugin enable' and 'omarchy plugin disable'. -'put' looks for --before / --after within the section it is adding to, and -falls back to the end of that section when the named widget is not there. +'put' places a widget the way 'plugin enable' does, but leaves one that is +already on the bar where it is, and falls back to the widget's usual spot when +--before / --after names a widget the bar does not carry. Examples: omarchy bar use local.neon-bar @@ -229,6 +230,38 @@ bar_widget_default_section() { ' <<<"$catalog" } +# Asks the shell to place a widget, waiting out one still coming up. Answers 0 +# with the reply in PUT_RESULT, 1 when there was no shell to ask. Only an +# absent shell is carried on from: omarchy-migrate marks a 0 return as done. +PUT_RESULT="" +SHELL_ANSWERED=0 +ask_to_put() { + local id="$1" placement="$2" attempt absent=0 + for (( attempt = 0; attempt < ${OMARCHY_SHELL_READY_ATTEMPTS:-50}; attempt++ )); do + if PUT_RESULT=$(omarchy-shell shell putBarWidget "$id" "$placement" 2>&1); then + SHELL_ANSWERED=1 + [[ $PUT_RESULT == "not ready" ]] || return 0 + elif [[ $PUT_RESULT == *"not ready"* ]]; then + SHELL_ANSWERED=1 + elif [[ $PUT_RESULT == *"is not running"* ]]; then + # Answered once and now gone: it stopped mid-request. + if (( SHELL_ANSWERED )); then + fail "omarchy-shell did not become ready; $id was not put on the bar" + fi + # A shell being spawned has no socket yet, and nothing says a launch is + # under way, so give one a few seconds to turn up. + if (( ++absent >= ${OMARCHY_SHELL_ABSENT_ATTEMPTS:-30} )); then + echo "omarchy-shell is not running; $id was not put on the bar" >&2 + return 1 + fi + else + fail "could not put $id on the bar: $PUT_RESULT" + fi + sleep 0.1 + done + fail "omarchy-shell did not become ready; $id was not put on the bar" +} + # Placement lives in the shell, which owns the config it has in memory. Putting # a widget therefore asks the shell rather than editing the file behind it. cmd_put() { @@ -250,19 +283,20 @@ cmd_put() { [[ -z $PLACEMENT_FROM_SECTION && -z $PLACEMENT_FROM_INDEX ]] || fail "put does not accept --from-section or --from-index" - # Nothing to place into with no shell to place it, and a caller that runs - # unattended should say so and carry on rather than fail. A shell that is - # there and refuses is a real error. - if ! omarchy-shell shell ping >/dev/null 2>&1; then - echo "omarchy-shell is not running; $id was not put on the bar" >&2 - return 0 + local placement + placement=$(placement_json) + ask_to_put "$id" "$placement" || return 0 + + # An update runs migrations before it restarts the shell, so this one can + # predate the fallback. Ask it again without the neighbour it cannot find. + if [[ $PUT_RESULT == "could not find target widget"* ]]; then + PLACEMENT_BEFORE="" + PLACEMENT_AFTER="" + ask_to_put "$id" "$(placement_json)" || return 0 fi - local result - result=$(omarchy-shell shell putBarWidget "$id" "$(placement_json)") || - fail "could not put $id on the bar" - [[ $result != "unknown" ]] || fail "$id is not a known widget; run 'omarchy plugin list'" - [[ $result == "ok" ]] || fail "$result" + [[ $PUT_RESULT != "unknown" ]] || fail "$id is not a known widget; run 'omarchy plugin list'" + [[ $PUT_RESULT == "ok" ]] || fail "$PUT_RESULT" # Says nothing about whether it had to be placed: a widget already on the bar # is left where it is, and both outcomes are the same answer to the caller. echo "$id is on the bar" diff --git a/bin/omarchy-shell b/bin/omarchy-shell index 5b44d339..ac8b3108 100755 --- a/bin/omarchy-shell +++ b/bin/omarchy-shell @@ -69,6 +69,11 @@ case $output in "Target not found." | "Function not found." | "Too few arguments provided"* | "Too many arguments provided"*) fail "$output" ;; + # A starting shell answers on stdout and exits 0, so a ping reads it as up + # and the next call's answer as a result. It is as unreachable as none. + "Not ready to accept queries yet"*) + fail "omarchy-shell is not ready" + ;; esac if (( !QUIET )) && [[ -n $output ]]; then diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index 38d67d5f..f08bf765 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -193,6 +193,16 @@ QtObject { return { found: false } } + // A caller naming a widget that has been cloned means the clone that took + // its place, the way resolveEnabledId routes calls to it. + function findRelativeBarLocation(config, id, section) { + var location = findBarLocation(config, id, section) + if (location.found) return location + if (!Util.isPlainObject(config) || !Util.isPlainObject(config.bar)) return { found: false } + var clone = activeCloneFor(config, Util.canonicalWidgetId(String(id))) + return clone ? findBarLocation(config, clone, section) : { found: false } + } + function findEntryLocation(config, id) { if (!Util.isPlainObject(config)) return { found: false } var key = Util.canonicalWidgetId(String(id)) @@ -218,7 +228,7 @@ QtObject { ? String(target.section) : fallbackSection var relativeId = String(target.before || target.after || "") if (relativeId) { - var relative = findBarLocation(config, relativeId, section && target.section ? section : "") + var relative = findRelativeBarLocation(config, relativeId, section && target.section ? section : "") if (!relative.found) return { error: "could not find target widget " + relativeId } return { section: relative.section, @@ -233,7 +243,7 @@ QtObject { } var anchors = { left: "omarchy.workspaces", center: "omarchy.weather", right: "omarchy.tray" } - var anchor = findBarLocation(config, anchors[section], section) + var anchor = findRelativeBarLocation(config, anchors[section], section) return { section: section, index: anchor.found ? anchor.index + 1 : config.bar.layout[section].length @@ -281,6 +291,29 @@ QtObject { return "" } + // put is the unattended verb: where enable errors, it falls back, and it + // leaves a widget that is already on the bar where its owner put it. + function putBarWidget(id, placement) { + if (inBar(id)) return "" + var config = shellConfigProvider ? shellConfigProvider() : null + // Enabling a source whose clone is active switches back to the built-in, + // which is the owner's call, not an unattended caller's. + if (findRelativeBarLocation(config, id, "").found) return "" + // The manifest scan is a subprocess and IPC answers before it returns, so + // an id it has not reached yet is not one that does not exist. + if (scanning && !installedPlugins[Util.canonicalWidgetId(String(id))]) return "not ready" + var target = Util.isPlainObject(placement) ? Util.cloneJson(placement) : {} + var relativeId = String(target.before || target.after || "") + if (relativeId) { + if (!findRelativeBarLocation(config, relativeId, String(target.section || "")).found) { + delete target.before + delete target.after + } + } + if (setEnabled(id, true, target)) return "" + return lastEnableError || "unknown" + } + function setBarWidget(id, key, value, selector) { var error = "" shellConfigMutator(function(config) { @@ -436,7 +469,7 @@ QtObject { if (value && placement && (placement.before || placement.after)) { var relativeId = String(placement.before || placement.after) - if (!findBarLocation(config, relativeId, String(placement.section || "")).found) { + if (!findRelativeBarLocation(config, relativeId, String(placement.section || "")).found) { lastEnableError = "could not find target widget " + relativeId return } diff --git a/shell/shell.qml b/shell/shell.qml index aa74ecba..1b8ae9c9 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -921,8 +921,12 @@ ShellRoot { // Enable, but only where the widget is not on the bar already, so a caller // that cannot know whether it ran before leaves a placed widget alone. function putBarWidget(id: string, placementJson: string): string { - if (shell.pluginRegistry.inBar(id)) return "ok" - return enablePlugin(id, placementJson) + try { + var error = shell.pluginRegistry.putBarWidget(id, JSON.parse(placementJson || "{}")) + return error ? error : "ok" + } catch (e) { + return "invalid placement: " + e + } } function moveBarWidget(id: string, placementJson: string): string { diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 281d6337..304998cc 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -22,6 +22,13 @@ const shellSource = fs.readFileSync(root + '/shell/shell.qml', 'utf8') assert(/function toggleBarTransparency\(\): string \{[\s\S]*?shell\.bar\.toggleTransparency\(\)/.test(shellSource), 'shell exposes the bar transparency toggle over IPC') +// put tolerates a placement target the bar does not carry, so the IPC call +// must reach the registry's put rather than route back through enable. +assert( + /function putBarWidget\(id: string, placementJson: string\): string \{[\s\S]*?shell\.pluginRegistry\.putBarWidget\(/.test(shellSource), + 'putting a bar widget over IPC goes through the registry put' +) + // Hiding must not unmap the bar. An unmapped layer surface has to be rebuilt on // every reveal, which measured ~150ms against ~20ms to tear it down; parking it // past the screen edge keeps show and hide symmetric at ~12ms. @@ -315,3 +322,133 @@ assertEqual( 'bar builds default custom module paths' ) JS + +put_tmp=$(mktemp -d) +trap 'rm -rf "$put_tmp"' EXIT +mkdir -p "$put_tmp/bin" +ln -s "$ROOT/bin/omarchy-shell-config" "$put_tmp/bin/omarchy-shell-config" + +cat >"$put_tmp/bin/omarchy-shell" <<'STUB' +#!/bin/bash +case ${OMARCHY_TEST_SHELL_STATE:-ready} in + missing) + echo "omarchy-shell is not running" >&2 + exit 1 + ;; + starting) + echo "omarchy-shell is not ready" >&2 + exit 1 + ;; + crashing) + # Seen coming up, then gone. + if [[ -e $OMARCHY_TEST_SHELL_MARKER ]]; then + echo "omarchy-shell is not running" >&2 + else + touch "$OMARCHY_TEST_SHELL_MARKER" + echo "omarchy-shell is not ready" >&2 + fi + exit 1 + ;; + oldshell) + # A shell from before put learned to fall back. + if [[ $4 == *"after"* ]]; then + echo "could not find target widget omarchy.clock" + else + echo "ok" + fi + exit 0 + ;; + spawning) + # Launched, but with no socket to answer on yet. + if [[ ! -e $OMARCHY_TEST_SHELL_MARKER ]]; then + touch "$OMARCHY_TEST_SHELL_MARKER" + echo "omarchy-shell is not running" >&2 + exit 1 + fi + ;; + vanishing) + # Answers the first ask, then is gone before the fallback lands. + if [[ ! -e $OMARCHY_TEST_SHELL_MARKER ]]; then + touch "$OMARCHY_TEST_SHELL_MARKER" + echo "could not find target widget omarchy.clock" + exit 0 + fi + echo "omarchy-shell is not running" >&2 + exit 1 + ;; + unsupported) + # An older shell that predates this call. + echo "Function not found." >&2 + exit 1 + ;; + scanning) + # Answering IPC, but has not read the plugins yet. + if [[ ! -e $OMARCHY_TEST_SHELL_MARKER ]]; then + touch "$OMARCHY_TEST_SHELL_MARKER" + echo "not ready" + exit 0 + fi + ;; +esac +echo "ok" +STUB +chmod +x "$put_tmp/bin/omarchy-shell" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=missing \ + OMARCHY_SHELL_ABSENT_ATTEMPTS=2 \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) || + fail "put carries on when no shell is running" "$put_output" +[[ $put_output == *"is not running"* ]] || fail "put says why it placed nothing" "$put_output" +pass "put carries on when no shell is running" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=spawning \ + OMARCHY_TEST_SHELL_MARKER="$put_tmp/spawned" \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) || + fail "put waits for a shell that is being spawned" "$put_output" +[[ $put_output == "omarchy.keyboard-layout is on the bar" ]] || fail "put places once the shell answers" "$put_output" +pass "put waits for a shell that is being spawned" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=starting OMARCHY_SHELL_READY_ATTEMPTS=2 \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) && + fail "put fails when the shell never becomes ready" "$put_output" +[[ $put_output == *"did not become ready"* ]] || fail "put says the shell never became ready" "$put_output" +pass "put fails when the shell never becomes ready" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=crashing \ + OMARCHY_TEST_SHELL_MARKER="$put_tmp/started" \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) && + fail "put fails when a starting shell disappears" "$put_output" +[[ $put_output == *"did not become ready"* ]] || fail "put keeps a lost shell retryable" "$put_output" +pass "put fails when a starting shell disappears" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=oldshell \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) || + fail "put falls back against a shell that has not restarted yet" "$put_output" +[[ $put_output == "omarchy.keyboard-layout is on the bar" ]] || fail "put places without the missing neighbour" "$put_output" +pass "put falls back against a shell that has not restarted yet" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=vanishing \ + OMARCHY_TEST_SHELL_MARKER="$put_tmp/vanished" \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) && + fail "put fails when the shell goes away mid-fallback" "$put_output" +[[ $put_output == *"did not become ready"* ]] || fail "put remembers the shell answered once" "$put_output" +pass "put fails when the shell goes away mid-fallback" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=unsupported \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) && + fail "put fails when the shell cannot answer the call" "$put_output" +[[ $put_output == *"Function not found"* ]] || fail "put passes on what the shell said" "$put_output" +pass "put fails when the shell cannot answer the call" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" OMARCHY_TEST_SHELL_STATE=scanning \ + OMARCHY_TEST_SHELL_MARKER="$put_tmp/scanned" \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) || + fail "put asks again while the shell is still reading its plugins" "$put_output" +[[ $put_output == "omarchy.keyboard-layout is on the bar" ]] || fail "put places once the plugins are read" "$put_output" +pass "put asks again while the shell is still reading its plugins" + +put_output=$(PATH="$put_tmp/bin:$ROOT/bin:$PATH" \ + "$ROOT/bin/omarchy-bar" put omarchy.keyboard-layout --after omarchy.clock 2>&1) || + fail "put places a widget through a ready shell" "$put_output" +[[ $put_output == "omarchy.keyboard-layout is on the bar" ]] || fail "put reports the placed widget" "$put_output" +pass "put places a widget through a ready shell" diff --git a/test/shell.d/fixtures/plugin-registry/shell.qml b/test/shell.d/fixtures/plugin-registry/shell.qml index b1d45eb8..e10f73ed 100644 --- a/test/shell.d/fixtures/plugin-registry/shell.qml +++ b/test/shell.d/fixtures/plugin-registry/shell.qml @@ -90,6 +90,9 @@ ShellRoot { var localWidget = manifest("local.first-widget", ["bar-widget"], { barWidget: "Widget.qml" }) localWidget.omarchy = { clonedFrom: "omarchy.first-widget" } scan += block("thirdparty", "/third/local-widget", localWidget) + var localWeather = manifest("local.weather", ["bar-widget"], { barWidget: "Widget.qml" }) + localWeather.omarchy = { clonedFrom: "omarchy.weather" } + scan += block("thirdparty", "/third/local-weather", localWeather) var localHybrid = manifest("local.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }) localHybrid.omarchy = { clonedFrom: "omarchy.hybrid" } scan += block("thirdparty", "/third/local-hybrid", localHybrid) @@ -115,6 +118,7 @@ ShellRoot { "local.first-widget", "local.grouped-panel", "local.hybrid", + "local.weather", "omarchy.bar", "omarchy.first-widget", "omarchy.grouped-panel", @@ -212,6 +216,101 @@ ShellRoot { registry.setEnabled("third.widget", true, { section: "right", index: 0 }) root.assertDeepEqual(root.config.bar.layout.right, [{ id: "third.widget" }], "enabling with placement is one registry transition") + // A bar the placement's neighbour is not on still gets the widget. + root.config = { + version: 1, + bar: { layout: { left: [], center: [{ id: "omarchy.weather" }], right: [] } }, + plugins: [] + } + root.assertTrue( + !registry.setEnabled("third.center-widget", true, { after: "omarchy.first-widget" }), + "enabling against a widget the bar does not carry is refused" + ) + root.assertEqual( + registry.lastEnableError, + "could not find target widget omarchy.first-widget", + "a refused enable names the target it could not find" + ) + root.assertDeepEqual(root.config.bar.layout.center, [{ id: "omarchy.weather" }], "a refused enable places nothing") + root.assertEqual( + registry.putBarWidget("third.center-widget", { after: "omarchy.first-widget" }), + "", + "put accepts a placement target the bar does not carry" + ) + root.assertDeepEqual( + root.config.bar.layout.center, + [{ id: "omarchy.weather" }, { id: "third.center-widget" }], + "put falls back to the section anchor when its target is missing" + ) + + // The anchor sits after the clone, so a fallback would land elsewhere. + root.config = { + version: 1, + bar: { layout: { left: [], center: [{ id: "local.first-widget" }, { id: "omarchy.weather" }], right: [] } }, + plugins: [] + } + root.assertEqual( + registry.putBarWidget("third.center-widget", { after: "omarchy.first-widget" }), + "", + "put places against a target that has been cloned" + ) + root.assertDeepEqual( + root.config.bar.layout.center, + [{ id: "local.first-widget" }, { id: "third.center-widget" }, { id: "omarchy.weather" }], + "a clone stands in for the widget it was cloned from as a placement target" + ) + + // The anchor a fallback lands against is as clonable as the target. + root.config = { + version: 1, + bar: { layout: { left: [], center: [{ id: "local.weather" }, { id: "omarchy.clock" }], right: [] } }, + plugins: [] + } + root.assertEqual( + registry.putBarWidget("third.center-widget", { after: "omarchy.first-widget" }), + "", + "put falls back past a cloned anchor" + ) + root.assertDeepEqual( + root.config.bar.layout.center, + [{ id: "local.weather" }, { id: "third.center-widget" }, { id: "omarchy.clock" }], + "a cloned anchor still anchors the section it was cloned into" + ) + + root.config = { + version: 1, + bar: { layout: { left: [{ id: "third.center-widget", size: 2 }], center: [], right: [] } }, + plugins: [] + } + root.assertEqual(registry.putBarWidget("third.center-widget", { section: "right" }), "", "put accepts a widget that is already on the bar") + root.assertDeepEqual( + root.config.bar.layout.left, + [{ id: "third.center-widget", size: 2 }], + "put leaves a widget that is already on the bar where its owner put it" + ) + root.assertDeepEqual(root.config.bar.layout.right, [], "put adds no second entry for a widget already on the bar") + root.assertEqual(registry.putBarWidget("third.absent", {}), "unknown", "put reports a widget it does not know") + + root.config = { + version: 1, + bar: { layout: { left: [], center: [{ id: "local.first-widget", size: 5 }], right: [] } }, + plugins: [] + } + root.assertEqual(registry.putBarWidget("omarchy.first-widget", {}), "", "put accepts a widget whose clone is on the bar") + root.assertDeepEqual( + root.config.bar.layout.center, + [{ id: "local.first-widget", size: 5 }], + "put leaves a clone of the widget it was asked to place alone" + ) + + // Refusing an id the scan has not reached would fail the migration. + root.config = { version: 1, bar: { layout: { left: [], center: [], right: [] } }, plugins: [] } + registry.scanning = true + root.assertEqual(registry.putBarWidget("third.absent", {}), "not ready", "put waits for a scan that has not reached its widget") + root.assertDeepEqual(root.config.bar.layout.center, [], "put places nothing while it is still waiting") + root.assertEqual(registry.putBarWidget("third.center-widget", {}), "", "put places a widget the scan has already read") + registry.scanning = false + root.config = { version: 1, bar: { layout: { left: [], center: [{ id: "omarchy.first-widget", size: 4 }], right: [] } }, diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index 6ca74744..ab9f0c54 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -27,6 +27,8 @@ cat >"$wrapper_bin/qs" <<'SH' if [[ ${OMARCHY_TEST_QS_HANG:-0} == 1 ]]; then sleep 5 +elif [[ ${OMARCHY_TEST_QS_STARTING:-0} == 1 ]]; then + printf 'Not ready to accept queries yet.\n' else printf 'ok\n' fi @@ -41,6 +43,21 @@ wrapper_error=$(PATH="$wrapper_bin:$PATH" \ [[ $wrapper_error == "omarchy-shell is not responding" ]] || fail "hung shell IPC reports that the shell is unresponsive" "$wrapper_error" pass "shell IPC calls time out when Quickshell is unresponsive" +# A starting shell answers on stdout and exits 0, so a ping reads it as up. +wrapper_error=$(PATH="$wrapper_bin:$PATH" \ + OMARCHY_PATH="$wrapper_root" \ + OMARCHY_TEST_QS_STARTING=1 \ + "$ROOT/bin/omarchy-shell" shell ping 2>&1) && fail "a starting shell answers IPC calls with a failure" +[[ $wrapper_error == "omarchy-shell is not ready" ]] || fail "a starting shell reports that it is not ready" "$wrapper_error" +pass "shell IPC calls fail while Quickshell is still starting" + +PATH="$wrapper_bin:$PATH" \ +OMARCHY_PATH="$wrapper_root" \ +OMARCHY_TEST_QS_STARTING=1 \ + "$ROOT/bin/omarchy-shell" -q shell ping >/dev/null 2>&1 || + fail "quiet best-effort IPC calls tolerate a starting shell" +pass "quiet best-effort IPC calls tolerate a starting shell" + wrapper_args="$test_tmp/wrapper-args" PATH="$wrapper_bin:$PATH" \ OMARCHY_PATH="$wrapper_root" \