From 1ac31e55ae0ceccffc080bce32ac61f5178dbb33 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 11 Oct 2026 14:42:00 +0200 Subject: [PATCH] Never leave the desktop without a wallpaper after a theme intro (#15000) A theme switch that plays an intro gave the shell the new still only when the intro failed. The shell kept the old theme's path, which the theme swap deletes, and loaded it again when OWE handed the desktop back, leaving it empty. The new still is now handed over either way. The shell also re-reads the background link whenever OWE hands the desktop back or a wallpaper fails to load, falling back to the theme's first background when the link itself is broken. Co-authored-by: Claude Opus 5.5 --- bin/omarchy-theme-bg-set | 4 +-- bin/omarchy-theme-set | 13 ++++--- shell/Ui/BackgroundMedia.qml | 3 ++ shell/plugins/background/Background.qml | 48 +++++++++++++++++++++++++ test/shell.d/theme-intro-switch-test.sh | 10 +++++- 5 files changed, 70 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-theme-bg-set b/bin/omarchy-theme-bg-set index 835321d7..34100c2f 100755 --- a/bin/omarchy-theme-bg-set +++ b/bin/omarchy-theme-bg-set @@ -20,6 +20,6 @@ fi # Create symlink to the new background media ln -nsf "$BACKGROUND" "$CURRENT_BACKGROUND_LINK" -# Update the live shell background immediately when it is running. The -# background plugin also polls this symlink, but IPC avoids the visible delay. +# Update the live shell background when it is running. Nothing watches the +# symlink, so this is how the shell learns of the new background. omarchy-shell -q background set "$BACKGROUND" diff --git a/bin/omarchy-theme-set b/bin/omarchy-theme-set index 87341efd..d6922c5d 100755 --- a/bin/omarchy-theme-set +++ b/bin/omarchy-theme-set @@ -207,11 +207,14 @@ set_theme_background() { shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK" ( - if ! omarchy-theme-bg-boot-intro --theme-switch "$new_background" "$theme_id" "$intro_prepared"; then - if [[ $(readlink -f "$CURRENT_BACKGROUND_LINK") == "$(readlink -f "$new_background")" && - $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then - shell_ipc background setInstant "$new_background" || true - fi + # Played or not, the shell gets the new still: it still holds the old + # theme's path, which the theme swap above deleted, and loads it again + # when OWE hands the desktop back after the intro. While the intro plays + # the shell's background is suspended under it, so the swap is unseen. + omarchy-theme-bg-boot-intro --theme-switch "$new_background" "$theme_id" "$intro_prepared" || true + if [[ $(readlink -f "$CURRENT_BACKGROUND_LINK") == "$(readlink -f "$new_background")" && + $(stat -Lc '%d:%i' "$CURRENT_THEME_PATH" 2>/dev/null) == "$theme_id" ]]; then + shell_ipc background setInstant "$new_background" || true fi shell_ipc shell finishThemeIntro "$theme_id" || true wait_for_theme_intro themeIntroStatus "$theme_id" "pending" diff --git a/shell/Ui/BackgroundMedia.qml b/shell/Ui/BackgroundMedia.qml index 8640769f..fd818598 100644 --- a/shell/Ui/BackgroundMedia.qml +++ b/shell/Ui/BackgroundMedia.qml @@ -18,6 +18,8 @@ Item { property size decodeSize: Qt.size(0, 0) readonly property var current: imageLoader.item readonly property bool ready: current ? current.ready : false + // The file behind path could not be loaded, as when it was deleted. + readonly property bool failed: current ? current.failed : false readonly property bool video: Util.isVideoPath(path) // Cache-bust images selected in a running lock session, so a theme switch // that replaces the file behind an unchanged path shows the new pixels. @@ -35,6 +37,7 @@ Item { Image { readonly property bool ready: status === Image.Ready + readonly property bool failed: status === Image.Error source: !root.constrainDecode || root.decodeSize.width > 0 ? root.imageUrl : "" fillMode: Image.PreserveAspectCrop asynchronous: true diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index d5e0741d..a05f110c 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -16,6 +16,7 @@ Item { readonly property string home: Quickshell.env("HOME") readonly property string stateHome: home + "/.local/state" readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background" + readonly property string currentThemeBackgrounds: stateHome + "/omarchy/current/theme/backgrounds" property string currentBackground: "" property string displayedBackground: "" @@ -66,6 +67,36 @@ Item { if (!readlinkProc.running) readlinkProc.running = true } + // The desktop must never be left without a wallpaper. A path that stops + // loading, say one that ran through a theme since replaced, is resolved + // again from the background link, which a theme switch always repoints. + // Only a different path is taken, so a file that is there but unreadable + // cannot loop. Resolving runs out of process, so a background chosen in the + // meantime wins over what it finds; if that one fails too, it is resolved + // in turn once the running lookup is done. + property string failedBackground: "" + property int recoverVersion: -1 + + function recoverBackground(failedPath) { + if (!failedPath || failedPath !== displayedBackground) return + failedBackground = failedPath + if (recoverProc.running) return + recoverVersion = backgroundVersion + recoverProc.running = true + } + + function finishRecovery(path) { + path = String(path || "").trim() + var failedPath = failedBackground + failedBackground = "" + if (recoverVersion !== backgroundVersion) { + recoverBackground(failedPath) + return + } + if (!path || path === failedPath) return + setBackground(path, true) + } + function setBackground(path, instant) { if (instant) reloadVersion += 1 transitionBackground("", path, path, instant, instant) @@ -85,6 +116,7 @@ Item { requestNativeSize(finalPath) currentBackground = finalPath backgroundVersion += 1 + failedBackground = "" revealStartedVersion = -1 revealAnimation.stop() @@ -220,6 +252,21 @@ Item { } } + // The link's target when it exists, or else the theme's first background, + // so a link left dangling still finds a wallpaper. + Process { + id: recoverProc + command: [ + "bash", "-c", + 'target=$(readlink -f "$1"); if [[ -e $target ]]; then echo "$target"; else ' + + 'find "$2" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.webp" \\) | sort | head -n 1; fi', + "resolve", root.currentBackgroundLink, root.currentThemeBackgrounds + ] + stdout: StdioCollector { + onStreamFinished: root.finishRecovery(text) + } + } + ShellIpc { target: "background" @@ -365,6 +412,7 @@ Item { cached: true constrainDecode: true decodeSize: panel.decodeSize(root.displayedBackground) + onFailedChanged: if (failed) root.recoverBackground(path) onReadyChanged: { panel.readyFrames = 0 if (ready && root.finishingTransition) { diff --git a/test/shell.d/theme-intro-switch-test.sh b/test/shell.d/theme-intro-switch-test.sh index 8c172b03..71ad1abe 100755 --- a/test/shell.d/theme-intro-switch-test.sh +++ b/test/shell.d/theme-intro-switch-test.sh @@ -119,6 +119,9 @@ grep -Fxq "owe: intro --start first-frame --refresh $state/theme/backgrounds/int [[ $(readlink "$state/background") == "$state/theme/backgrounds/2-road.webp" ]] || fail "the remembered background is selected" ! grep -qE '^shell: background (prepare|themeTransition)' "$log" || fail "an intro replaces the normal still transition" [[ $(<"$home/.local/state/omarchy/background-intro.session-id") == already-consumed ]] || fail "a theme switch leaves login consumption unchanged" +# The shell holds the old theme's still, whose path the swap deleted, and loads +# it again when OWE hands the desktop back, so it gets the new one either way. +wait_command "^shell: background setInstant $state/theme/backgrounds/2-road.webp\$" pass "switching themes plays the matching intro for the remembered background" set_theme beta @@ -176,7 +179,12 @@ wait_command '^owe: intro ' [[ ! -f $gates/premature-swap ]] || fail "the outgoing fade and cover settle before replacing the active theme" pass "rapid switches present the outgoing video cover before replacing its still" -wait_command '^hypr-reload$' +# Earlier switches finish in the background, so the next one waits for them, +# or a late reload of theirs reads as one during its intro. +for attempt in {1..250}; do + pgrep -f "^/bin/bash $ROOT/bin/omarchy-theme-set (alpha|beta)\$" >/dev/null || break + sleep 0.02 +done release="$test_tmp/reload-release" TEST_INTRO_PID="$test_tmp/intro.pid" TEST_RELEASE="$release" set_theme alpha wait_command '^owe: intro '