From b423f4993d68b0db70ae778f8cd4b3f516e3c500 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 20 Sep 2026 20:36:19 -0500 Subject: [PATCH] Complete OWE service setup and lock feed fallback --- install/omarchy-base.packages | 6 +- install/user/first-run/enable-user-units.sh | 3 + manual/39-backgrounds.md | 2 + migrations/1786609204.sh | 4 +- migrations/1789764927.sh | 17 ++++ shell/plugins/background/Background.qml | 7 +- shell/plugins/lock/LockFeedSurface.qml | 4 - shell/plugins/lock/LockView.qml | 37 ++++----- shell/plugins/lock/Service.qml | 29 +++++++ shell/plugins/lock/poster.sh | 24 ++++++ test/shell.d/fixtures/owe-lock/shell.qml | 69 ++++++++++++++++ test/shell.d/owe-integration-test.sh | 90 +++++++++++++++++++++ test/shell.d/owe-lock-test.sh | 30 +++++++ test/shell.d/video-background-test.sh | 10 +-- 14 files changed, 289 insertions(+), 43 deletions(-) create mode 100644 migrations/1789764927.sh create mode 100755 shell/plugins/lock/poster.sh create mode 100644 test/shell.d/fixtures/owe-lock/shell.qml create mode 100644 test/shell.d/owe-integration-test.sh create mode 100644 test/shell.d/owe-lock-test.sh diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index c292828f..e4dd69a2 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -95,6 +95,9 @@ omacalc omacut omawrite omarchy-nvim +# OWE owns desktop video backgrounds and feeds the lock screen its frames. +owe +owe-lockfeed pacman-contrib pamixer pinta @@ -107,9 +110,6 @@ python-poetry-core ttfx qemu-user-static-binfmt qrencode -# OWE owns desktop video backgrounds and feeds the lock screen its frames. -owe -owe-lockfeed qt6-imageformats quickshell ripgrep diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index 9865fc20..e08885c9 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -14,8 +14,11 @@ set -euo pipefail systemctl --user daemon-reload systemctl --user enable --now \ bt-agent.service \ + owed.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ omarchy-migrate-notify.service \ omarchy-fcitx5.service \ omarchy-crash-watch.service + +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync diff --git a/manual/39-backgrounds.md b/manual/39-backgrounds.md index f57d23d2..3bc1f77b 100644 --- a/manual/39-backgrounds.md +++ b/manual/39-backgrounds.md @@ -7,3 +7,5 @@ You can do this most easily by going to _Install > Style > Background_ in the Om Backgrounds can be videos as well as stills. Drop an `mp4`, `m4v`, `mov`, `webm`, `mkv`, or `avi` file in the same folder and it appears alongside the images. Videos are played by the OWE wallpaper engine. It decodes the video once for all monitors and plays its sound through the default audio output, and it stops playback whenever nothing can see it. The lock screen draws the same decode, muted, through OWE. A video wallpaper still costs far more power than a still one. You can find a huge collection of cool curated backgrounds on https://github.com/dharmx/walls. + +Video backgrounds keep a cached still on the lock screen while playback is paused or unavailable. Animated GIFs play on the desktop and show a still frame on the lock screen. diff --git a/migrations/1786609204.sh b/migrations/1786609204.sh index b7833092..ce1442ca 100644 --- a/migrations/1786609204.sh +++ b/migrations/1786609204.sh @@ -1,3 +1 @@ -echo "Install native video wallpaper playback dependencies" - -omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg +echo "Native video dependencies are superseded by the OWE migration" diff --git a/migrations/1789764927.sh b/migrations/1789764927.sh new file mode 100644 index 00000000..e2d1541e --- /dev/null +++ b/migrations/1789764927.sh @@ -0,0 +1,17 @@ +echo "Enable OWE desktop video backgrounds and lock feed" + +omarchy-pkg-add owe owe-lockfeed +omarchy-hook-install theme-set /usr/share/owe/10-owe-sync + +systemctl --user daemon-reload >/dev/null 2>&1 || true +if ! systemctl --user enable owed.service; then + wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/owed.service "$wants_dir/owed.service" +fi + +# A TTY update enables the next graphical login without starting a renderer +# against a missing Wayland session. A failed live start leaves this pending. +if [[ ${OMARCHY_UPGRADE_TO_QUATTRO_LIVE:-0} != 1 ]] && systemctl --user is-active --quiet graphical-session.target; then + systemctl --user start owed.service +fi diff --git a/shell/plugins/background/Background.qml b/shell/plugins/background/Background.qml index 8c113e4b..b3dd7dab 100644 --- a/shell/plugins/background/Background.qml +++ b/shell/plugins/background/Background.qml @@ -182,9 +182,7 @@ Item { } } - Component.onCompleted: { - refreshBackground() - } + Component.onCompleted: refreshBackground() Variants { model: Quickshell.screens @@ -205,8 +203,7 @@ Item { // Keep render updates enabled. The background layer has been observed to // lose its committed buffer while parked with updatesEnabled=false, // leaving a black desktop until omarchy-shell is restarted. A still - // wallpaper costs nothing to keep enabled, and a video one is throttled - // by pausing playback rather than by parking the layer. + // wallpaper costs nothing to keep enabled. OWE manages video layers. updatesEnabled: true property bool maskReady: false diff --git a/shell/plugins/lock/LockFeedSurface.qml b/shell/plugins/lock/LockFeedSurface.qml index 38bd4612..ecd1f7d4 100644 --- a/shell/plugins/lock/LockFeedSurface.qml +++ b/shell/plugins/lock/LockFeedSurface.qml @@ -4,8 +4,4 @@ import Owe.LockFeed // Loaded through a Loader from LockView, so a system without the module shows // no lock video instead of losing the whole lock screen. LockFeed { - id: root - - property bool feedEnabled: true - active: root.feedEnabled } diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index 9366b289..7084fb0f 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -7,6 +7,7 @@ Item { id: root property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property bool fingerprintConfigured: false property bool authenticatingPassword: false @@ -91,34 +92,15 @@ Item { BackgroundMedia { id: wallpaper + objectName: "lockWallpaper" anchors.fill: parent - path: root.video ? "" : root.backgroundPath + path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : "" version: root.backgroundVersion - visible: !root.video - } - - // OWE decodes the video once and feeds these frames to every lock surface. - // The feed pauses when this output blanks or on battery power saver. - Loader { - id: feedLoader - anchors.fill: parent - active: root.video - source: "LockFeedSurface.qml" - visible: status === Loader.Ready - - Binding { - target: feedLoader.item - property: "feedEnabled" - value: root.feedActive - when: feedLoader.item !== null - restoreMode: Binding.RestoreNone - } } MultiEffect { anchors.fill: wallpaper - source: root.video ? null : wallpaper - visible: !root.video + source: wallpaper autoPaddingEnabled: false blurEnabled: root.loadBackground && wallpaper.ready blur: 1.0 @@ -127,6 +109,17 @@ Item { contrast: -0.08 } + // The cached poster stays behind the feed when policy pauses playback, + // the module is unavailable, or a new connection has not received a frame. + Loader { + id: feedLoader + objectName: "lockFeedLoader" + anchors.fill: parent + active: root.feedActive + source: "LockFeedSurface.qml" + visible: status === Loader.Ready + } + // The feed item cannot be sampled by MultiEffect on every renderer. // Keep video wallpapers visible and darken them slightly for legibility. Rectangle { diff --git a/shell/plugins/lock/Service.qml b/shell/plugins/lock/Service.qml index 94d43b68..6e1f703e 100644 --- a/shell/plugins/lock/Service.qml +++ b/shell/plugins/lock/Service.qml @@ -28,6 +28,7 @@ Item { property string failureMessage: "" property int failedAttempts: 0 property string backgroundPath: "" + property string videoPosterPath: "" property int backgroundVersion: 0 property string lastEvent: "init" property string lastEventAt: "" @@ -113,6 +114,16 @@ Item { if (!readlinkProc.running) readlinkProc.running = true } + function refreshPoster() { + if (!root.videoBackground) { + root.videoPosterPath = "" + return + } + if (posterProc.running) return + posterProc.sourcePath = root.backgroundPath + posterProc.running = true + } + function refreshFingerprintStatus() { if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true } @@ -307,6 +318,7 @@ Item { id: lockView anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: root.authenticatingPassword @@ -339,6 +351,7 @@ Item { LockView { anchors.fill: parent backgroundPath: root.backgroundPath + videoPosterPath: root.videoPosterPath backgroundVersion: root.backgroundVersion fingerprintConfigured: root.fingerprintConfigured authenticatingPassword: false @@ -409,9 +422,25 @@ Item { onStreamFinished: { var next = String(text || "").trim() if (next !== root.backgroundPath) { + root.videoPosterPath = "" root.backgroundPath = next root.backgroundVersion += 1 } + root.refreshPoster() + } + } + } + + Process { + id: posterProc + property string sourcePath: "" + command: ["bash", Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/poster.sh", sourcePath] + stdout: StdioCollector { id: posterOutput; waitForEnd: true } + onExited: function(exitCode) { + if (sourcePath !== root.backgroundPath) { + root.refreshPoster() + } else { + root.videoPosterPath = exitCode === 0 ? String(posterOutput.text || "").trim() : "" } } } diff --git a/shell/plugins/lock/poster.sh b/shell/plugins/lock/poster.sh new file mode 100755 index 00000000..836d01f0 --- /dev/null +++ b/shell/plugins/lock/poster.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# One cached frame shared by all lock outputs, including when OWE is paused. +set -euo pipefail + +source_path=$1 +cache_dir="${XDG_CACHE_HOME:-$HOME/.cache}/omarchy/lock-poster" +signature=$(stat -Lc '%s:%y:%z' "$source_path") +key=$(printf '%s\n%s' "$source_path" "$signature" | sha256sum | cut -d ' ' -f 1) +poster="$cache_dir/poster-$key.jpg" +mkdir -p "$cache_dir" +exec {lock_fd}>"$cache_dir/.lock" +flock -w 10 "$lock_fd" + +if [[ ! -s $poster ]]; then + temporary=$(mktemp "$cache_dir/.poster-XXXXXX.jpg") + trap 'rm -f "$temporary"' EXIT + timeout -k 1 5 ffmpegthumbnailer -i "$source_path" -o "$temporary" -s 1920 -q 8 {lock_fd}>&- + [[ -s $temporary ]] + mv -f "$temporary" "$poster" + find "$cache_dir" -maxdepth 1 -type f -name 'poster-*.jpg' ! -name "poster-$key.jpg" -delete +fi + +printf '%s\n' "$poster" diff --git a/test/shell.d/fixtures/owe-lock/shell.qml b/test/shell.d/fixtures/owe-lock/shell.qml new file mode 100644 index 00000000..dc9f88fa --- /dev/null +++ b/test/shell.d/fixtures/owe-lock/shell.qml @@ -0,0 +1,69 @@ +import QtQuick +import Quickshell + +ShellRoot { + id: root + property var view + property var wallpaper + property var feed + property int step: 0 + + Item { id: host; width: 1000; height: 700 } + + function findItem(item, name) { + if (item.objectName === name) return item + for (var i = 0; i < item.children.length; i++) { + var found = findItem(item.children[i], name) + if (found) return found + } + return null + } + + function check(condition, message) { + if (!condition) throw new Error(message) + } + + Timer { + interval: 150 + repeat: true + running: true + onTriggered: { + try { + if (root.step === 0) { + var component = Qt.createComponent("file://" + Quickshell.env("OMARCHY_PATH") + "/shell/plugins/lock/LockView.qml") + check(component.status === Component.Ready, component.errorString()) + root.view = component.createObject(host, {width: 1000, height: 700, backgroundPath: "/still.png", loadBackground: false}) + check(root.view !== null, component.errorString()) + root.wallpaper = findItem(root.view, "lockWallpaper") + root.feed = findItem(root.view, "lockFeedLoader") + check(root.wallpaper.path === "", "hidden still must not decode") + root.view.backgroundPath = "/video.mp4" + root.view.videoPosterPath = "/poster.jpg" + } else if (root.step === 1) { + check(!root.feed.active && root.feed.item === null, "hidden video must not load a feed client") + root.view.loadBackground = true + root.view.powerSaverActive = true + } else if (root.step === 2) { + check(root.wallpaper.path === "/poster.jpg", "power saver keeps the video poster") + check(!root.feed.active, "power saver must not connect to the feed") + root.view.powerSaverActive = false + } else if (root.step === 3) { + check(root.feed.active, "visible video enables the isolated feed loader") + check(root.wallpaper.path === "/poster.jpg", "poster stays beneath the feed before its first frame") + root.view.displaysBlank = true + } else if (root.step === 4) { + check(!root.feed.active && root.feed.item === null, "blanking releases the feed client") + root.view.loadBackground = false + } else { + check(root.wallpaper.path === "", "unlock releases the poster image") + console.log("OWE_LOCK_TEST_PASS") + Qt.quit() + } + root.step++ + } catch (error) { + console.error("OWE_LOCK_TEST_FAIL: " + error) + Qt.quit() + } + } + } +} diff --git a/test/shell.d/owe-integration-test.sh b/test/shell.d/owe-integration-test.sh new file mode 100644 index 00000000..cde79db2 --- /dev/null +++ b/test/shell.d/owe-integration-test.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/home" +export TEST_CALLS="$work/calls" +for command in omarchy-pkg-add omarchy-hook-install systemctl; do + cat >"$work/bin/$command" <<'SH' +#!/bin/bash +printf '%s %s\n' "${0##*/}" "$*" >>"$TEST_CALLS" +case "$*" in + '--user enable owed.service') [[ ${TEST_TTY:-0} == 0 ]] ;; + '--user is-active --quiet graphical-session.target') [[ ${TEST_TTY:-0} == 0 ]] ;; + '--user start owed.service') [[ ${TEST_START_FAIL:-0} == 0 ]] ;; + *) exit 0 ;; +esac +SH + chmod +x "$work/bin/$command" +done + +migration="$ROOT/migrations/1789764927.sh" +run_migration() { + HOME="$work/home" PATH="$work/bin:$PATH" bash -euo pipefail "$migration" +} +run_migration +run_migration +grep -Fx 'omarchy-pkg-add owe owe-lockfeed' "$TEST_CALLS" >/dev/null +grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null +grep -Fx 'systemctl --user start owed.service' "$TEST_CALLS" >/dev/null +pass "migration installs OWE, its theme refresh hook, and starts the graphical session service" + +: >"$TEST_CALLS" +TEST_TTY=1 run_migration +TEST_TTY=1 run_migration +[[ $(readlink "$work/home/.config/systemd/user/graphical-session.target.wants/owed.service") == /usr/lib/systemd/user/owed.service ]] +if grep -F 'systemctl --user start' "$TEST_CALLS" >/dev/null; then + fail "TTY migration must not start the renderer" +fi +pass "TTY migrations enable the next login without starting a renderer" + +: >"$TEST_CALLS" +OMARCHY_UPGRADE_TO_QUATTRO_LIVE=1 run_migration +if grep -F 'systemctl --user start' "$TEST_CALLS" >/dev/null; then + fail "Quattro upgrade must defer OWE until the new graphical session" +fi +pass "Quattro upgrade enables OWE without starting it in the old shell" + +if TEST_START_FAIL=1 run_migration; then + fail "a failed live service start leaves the migration pending" +fi +pass "a failed live service start fails the migration" + +: >"$TEST_CALLS" +HOME="$work/home" PATH="$work/bin:$PATH" bash "$ROOT/install/user/first-run/enable-user-units.sh" +grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null +grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null +pass "fresh installs enable OWE and install the theme refresh hook" + +cat >"$work/bin/ffmpegthumbnailer" <<'SH' +#!/bin/bash +printf 'thumbnail\n' >>"$TEST_CALLS" +while (( $# )); do + case "$1" in + -o) output=$2; shift 2 ;; + *) shift ;; + esac +done +[[ ${TEST_POSTER_FAIL:-0} == 0 ]] || exit 1 +printf 'poster\n' >"$output" +SH +chmod +x "$work/bin/ffmpegthumbnailer" +source_path="$work/video with 'quotes'.mp4" +printf 'source\n' >"$source_path" +poster() { + PATH="$work/bin:$PATH" XDG_CACHE_HOME="$work/cache" bash "$ROOT/shell/plugins/lock/poster.sh" "$source_path" +} +: >"$TEST_CALLS" +first=$(poster) +[[ -s $first && $(poster) == "$first" ]] +[[ $(wc -l <"$TEST_CALLS") == 1 ]] +printf 'changed source\n' >>"$source_path" +second=$(poster) +[[ -s $second && $second != "$first" && ! -e $first ]] +pass "lock posters are cached, refreshed after source changes, and old frames are evicted" +printf 'broken source\n' >>"$source_path" +if TEST_POSTER_FAIL=1 poster; then fail "failed poster generation must fail"; fi +[[ -z $(find "$work/cache" -name '.poster-*.jpg' -print -quit) ]] +pass "failed poster generation never publishes a partial image" diff --git a/test/shell.d/owe-lock-test.sh b/test/shell.d/owe-lock-test.sh new file mode 100644 index 00000000..4f3831c7 --- /dev/null +++ b/test/shell.d/owe-lock-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell unavailable; skipping lock feed QML lifecycle" + exit 0 +fi +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/config" "$work/runtime" "$work/home" +chmod 700 "$work/runtime" +cp "$SHELL_TEST_DIR/fixtures/owe-lock/shell.qml" "$work/config/shell.qml" +ln -s "$ROOT/shell/Ui" "$work/config/Ui" +ln -s "$ROOT/shell/Commons" "$work/config/Commons" + +# Offscreen rendering exercises the real QML bindings without taking a lock +# or connecting to the user's compositor. +HOME="$work/home" XDG_RUNTIME_DIR="$work/runtime" \ + OMARCHY_PATH="$ROOT" QT_QPA_PLATFORM=offscreen QT_QPA_PLATFORMTHEME= \ + QT_STYLE_OVERRIDE= QT_QUICK_BACKEND=software \ + timeout 15 quickshell -p "$work/config" --no-color >"$work/log" 2>&1 || { + cat "$work/log" >&2 + fail "lock feed QML lifecycle runs" + } +if ! grep -q 'OWE_LOCK_TEST_PASS' "$work/log" || grep -q 'OWE_LOCK_TEST_FAIL' "$work/log"; then + cat "$work/log" >&2 + fail "lock feed respects visibility and keeps its poster fallback" +fi +pass "lock feed respects visibility, power saver and blanking; hidden views release images" diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 31ac754d..761c97a9 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -49,11 +49,9 @@ assert( assert( /^import Owe\.LockFeed$/m.test(lockFeedQml) && lockFeedQml.includes('LockFeed {') && - lockFeedQml.includes('active: root.feedEnabled') && lockQml.includes('source: "LockFeedSurface.qml"') && - lockQml.includes('active: root.video') && - /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && - /property: "feedEnabled"[\s\S]*?value: root\.feedActive/.test(lockQml), + lockQml.includes('active: root.feedActive') && + /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml), 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video' ) assert( @@ -61,8 +59,8 @@ assert( 'the lock view itself carries no foreign import, so the lock still loads without the feed module' ) assert( - lockQml.includes('path: root.video ? "" : root.backgroundPath') && - lockQml.includes('visible: !root.video') && + lockQml.includes('path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""') && + lockQml.indexOf('id: feedLoader') > lockQml.indexOf('MultiEffect {') && lockQml.includes('visible: root.video') && !lockQml.includes('wallpaper.video'), 'the lock screen keeps its image effect for stills and shows the feed for videos'