Complete OWE service setup and lock feed fallback

This commit is contained in:
David Heinemeier Hansson committed 2026-09-20 20:36:19 -05:00
1 parent b0bf4876b1
commit b423f4993d
14 files changed
+289 -43

No files matched your search

+3 -3
View File
@@ -95,6 +95,9 @@ omacalc
omacut omacut
omawrite omawrite
omarchy-nvim omarchy-nvim
# OWE owns desktop video backgrounds and feeds the lock screen its frames.
owe
owe-lockfeed
pacman-contrib pacman-contrib
pamixer pamixer
pinta pinta
@@ -107,9 +110,6 @@ python-poetry-core
ttfx ttfx
qemu-user-static-binfmt qemu-user-static-binfmt
qrencode qrencode
# OWE owns desktop video backgrounds and feeds the lock screen its frames.
owe
owe-lockfeed
qt6-imageformats qt6-imageformats
quickshell quickshell
ripgrep ripgrep
@@ -14,8 +14,11 @@ set -euo pipefail
systemctl --user daemon-reload systemctl --user daemon-reload
systemctl --user enable --now \ systemctl --user enable --now \
bt-agent.service \ bt-agent.service \
owed.service \
omarchy-recover-internal-monitor.service \ omarchy-recover-internal-monitor.service \
omarchy-sleep-lock.service \ omarchy-sleep-lock.service \
omarchy-migrate-notify.service \ omarchy-migrate-notify.service \
omarchy-fcitx5.service \ omarchy-fcitx5.service \
omarchy-crash-watch.service omarchy-crash-watch.service
omarchy-hook-install theme-set /usr/share/owe/10-owe-sync
+2
View File
@@ -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. 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. 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.
+1 -3
View File
@@ -1,3 +1 @@
echo "Install native video wallpaper playback dependencies" echo "Native video dependencies are superseded by the OWE migration"
omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg
+17
View File
@@ -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
+2 -5
View File
@@ -182,9 +182,7 @@ Item {
} }
} }
Component.onCompleted: { Component.onCompleted: refreshBackground()
refreshBackground()
}
Variants { Variants {
model: Quickshell.screens model: Quickshell.screens
@@ -205,8 +203,7 @@ Item {
// Keep render updates enabled. The background layer has been observed to // Keep render updates enabled. The background layer has been observed to
// lose its committed buffer while parked with updatesEnabled=false, // lose its committed buffer while parked with updatesEnabled=false,
// leaving a black desktop until omarchy-shell is restarted. A still // leaving a black desktop until omarchy-shell is restarted. A still
// wallpaper costs nothing to keep enabled, and a video one is throttled // wallpaper costs nothing to keep enabled. OWE manages video layers.
// by pausing playback rather than by parking the layer.
updatesEnabled: true updatesEnabled: true
property bool maskReady: false property bool maskReady: false
-4
View File
@@ -4,8 +4,4 @@ import Owe.LockFeed
// Loaded through a Loader from LockView, so a system without the module shows // Loaded through a Loader from LockView, so a system without the module shows
// no lock video instead of losing the whole lock screen. // no lock video instead of losing the whole lock screen.
LockFeed { LockFeed {
id: root
property bool feedEnabled: true
active: root.feedEnabled
} }
+15 -22
View File
@@ -7,6 +7,7 @@ Item {
id: root id: root
property string backgroundPath: "" property string backgroundPath: ""
property string videoPosterPath: ""
property int backgroundVersion: 0 property int backgroundVersion: 0
property bool fingerprintConfigured: false property bool fingerprintConfigured: false
property bool authenticatingPassword: false property bool authenticatingPassword: false
@@ -91,34 +92,15 @@ Item {
BackgroundMedia { BackgroundMedia {
id: wallpaper id: wallpaper
objectName: "lockWallpaper"
anchors.fill: parent anchors.fill: parent
path: root.video ? "" : root.backgroundPath path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""
version: root.backgroundVersion 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 { MultiEffect {
anchors.fill: wallpaper anchors.fill: wallpaper
source: root.video ? null : wallpaper source: wallpaper
visible: !root.video
autoPaddingEnabled: false autoPaddingEnabled: false
blurEnabled: root.loadBackground && wallpaper.ready blurEnabled: root.loadBackground && wallpaper.ready
blur: 1.0 blur: 1.0
@@ -127,6 +109,17 @@ Item {
contrast: -0.08 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. // The feed item cannot be sampled by MultiEffect on every renderer.
// Keep video wallpapers visible and darken them slightly for legibility. // Keep video wallpapers visible and darken them slightly for legibility.
Rectangle { Rectangle {
+29
View File
@@ -28,6 +28,7 @@ Item {
property string failureMessage: "" property string failureMessage: ""
property int failedAttempts: 0 property int failedAttempts: 0
property string backgroundPath: "" property string backgroundPath: ""
property string videoPosterPath: ""
property int backgroundVersion: 0 property int backgroundVersion: 0
property string lastEvent: "init" property string lastEvent: "init"
property string lastEventAt: "" property string lastEventAt: ""
@@ -113,6 +114,16 @@ Item {
if (!readlinkProc.running) readlinkProc.running = true 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() { function refreshFingerprintStatus() {
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
} }
@@ -307,6 +318,7 @@ Item {
id: lockView id: lockView
anchors.fill: parent anchors.fill: parent
backgroundPath: root.backgroundPath backgroundPath: root.backgroundPath
videoPosterPath: root.videoPosterPath
backgroundVersion: root.backgroundVersion backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: root.authenticatingPassword authenticatingPassword: root.authenticatingPassword
@@ -339,6 +351,7 @@ Item {
LockView { LockView {
anchors.fill: parent anchors.fill: parent
backgroundPath: root.backgroundPath backgroundPath: root.backgroundPath
videoPosterPath: root.videoPosterPath
backgroundVersion: root.backgroundVersion backgroundVersion: root.backgroundVersion
fingerprintConfigured: root.fingerprintConfigured fingerprintConfigured: root.fingerprintConfigured
authenticatingPassword: false authenticatingPassword: false
@@ -409,9 +422,25 @@ Item {
onStreamFinished: { onStreamFinished: {
var next = String(text || "").trim() var next = String(text || "").trim()
if (next !== root.backgroundPath) { if (next !== root.backgroundPath) {
root.videoPosterPath = ""
root.backgroundPath = next root.backgroundPath = next
root.backgroundVersion += 1 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() : ""
} }
} }
} }
+24
View File
@@ -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"
+69
View File
@@ -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()
}
}
}
}
+90
View File
@@ -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"
+30
View File
@@ -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"
+4 -6
View File
@@ -49,11 +49,9 @@ assert(
assert( assert(
/^import Owe\.LockFeed$/m.test(lockFeedQml) && /^import Owe\.LockFeed$/m.test(lockFeedQml) &&
lockFeedQml.includes('LockFeed {') && lockFeedQml.includes('LockFeed {') &&
lockFeedQml.includes('active: root.feedEnabled') &&
lockQml.includes('source: "LockFeedSurface.qml"') && lockQml.includes('source: "LockFeedSurface.qml"') &&
lockQml.includes('active: root.video') && lockQml.includes('active: root.feedActive') &&
/feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) && /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml),
/property: "feedEnabled"[\s\S]*?value: root\.feedActive/.test(lockQml),
'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video' 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video'
) )
assert( assert(
@@ -61,8 +59,8 @@ assert(
'the lock view itself carries no foreign import, so the lock still loads without the feed module' 'the lock view itself carries no foreign import, so the lock still loads without the feed module'
) )
assert( assert(
lockQml.includes('path: root.video ? "" : root.backgroundPath') && lockQml.includes('path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""') &&
lockQml.includes('visible: !root.video') && lockQml.indexOf('id: feedLoader') > lockQml.indexOf('MultiEffect {') &&
lockQml.includes('visible: root.video') && lockQml.includes('visible: root.video') &&
!lockQml.includes('wallpaper.video'), !lockQml.includes('wallpaper.video'),
'the lock screen keeps its image effect for stills and shows the feed for videos' 'the lock screen keeps its image effect for stills and shows the feed for videos'