Merge pull request #12429 from omacom/owe-video-backgrounds

Replace the shell's desktop video path with OWE
This commit is contained in:
David Heinemeier Hansson authored and GitHub committed 2026-09-21 03:55:16 +02:00
commit ab18321bb5
17 files changed
+355 -307

No files matched your search

+3 -2
View File
@@ -96,6 +96,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
@@ -109,8 +112,6 @@ ttfx
qemu-user-static-binfmt qemu-user-static-binfmt
qrencode qrencode
qt6-imageformats qt6-imageformats
qt6-multimedia
qt6-multimedia-ffmpeg
quickshell quickshell
ripgrep ripgrep
ruby ruby
@@ -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
+3 -1
View File
@@ -4,6 +4,8 @@ Every theme ships with its own set of backgrounds, and you can add extras of you
You can do this most easily by going to _Install > Style > Background_ in the Omarchy Menu. That'll bring up the folder where the backgrounds for that theme is stored. Hit `Super + Shift + F` to start another file manager, find your background, copy it over. Now it'll be included in the choices of backgrounds you can select between using `Super + Ctrl + Space`. You can do this most easily by going to _Install > Style > Background_ in the Omarchy Menu. That'll bring up the folder where the backgrounds for that theme is stored. Hit `Super + Shift + F` to start another file manager, find your background, copy it over. Now it'll be included in the choices of backgrounds you can select between using `Super + Ctrl + Space`.
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, playing on a loop. Only your first monitor's wallpaper plays a video's sound track, through the default audio output at the system volume, and the lock screen stays silent. Playback stops on its own whenever nothing can see it — while a fullscreen window covers that monitor, while the screensaver is up, and once a locked screen has gone dark — but a video wallpaper still costs far more power than a still one, and each monitor decodes its own copy. 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
+6 -55
View File
@@ -1,29 +1,19 @@
import QtQuick import QtQuick
import qs.Commons import qs.Commons
// The shell draws stills only. OWE owns video backgrounds on the desktop, and
// it feeds the lock screen through its own socket.
Item { Item {
id: root id: root
property string path: "" property string path: ""
property int version: 0 property int version: 0
property bool playbackEnabled: true readonly property var current: imageLoader.item
property bool audioEnabled: false
// Bumped when the file behind an unchanged path may have been replaced.
// Images cache-bust through version; a video is rebuilt, since FFmpeg
// would read a query as part of the filename.
property int reloads: 0
property bool reloading: false
readonly property var current: video ? videoLoader.item : imageLoader.item
readonly property bool ready: current ? current.ready : false readonly property bool ready: current ? current.ready : false
readonly property bool video: Util.isVideoPath(path) readonly property bool video: Util.isVideoPath(path)
// Cache-bust images selected in a running lock session. FFmpeg treats the // Cache-bust images selected in a running lock session, so a theme switch
// query as part of a local filename, so videos must keep their plain URL. // that replaces the file behind an unchanged path shows the new pixels.
// Each URL is empty for the other kind, so a switch never hands the still readonly property url imageUrl: path && !video ? Util.fileUrl(path) + (version ? "?v=" + version : "") : ""
// loader a video, or the player a still, in the moment before it unloads.
// Both test the path directly: going through `video` lets a URL evaluate
// against the stale flag and leak the wrong file for one pass.
readonly property url imageUrl: path && !Util.isVideoPath(path) ? Util.fileUrl(path) + (version ? "?v=" + version : "") : ""
readonly property url videoUrl: path && Util.isVideoPath(path) ? Util.fileUrl(path) : ""
Loader { Loader {
id: imageLoader id: imageLoader
@@ -32,45 +22,6 @@ Item {
sourceComponent: imageComponent sourceComponent: imageComponent
} }
// Loaded by URL rather than from a Component here, so QtMultimedia and its
// audio dependency closure never map into a session that only shows images.
Loader {
id: videoLoader
anchors.fill: parent
active: root.path !== "" && root.video && !root.reloading
source: "BackgroundVideo.qml"
}
onReloadsChanged: {
if (!video) return
reloading = true
Qt.callLater(function() { root.reloading = false })
}
// A player on its way out keeps its source: pushing an empty one starts a
// load of nothing that its destructor then cancels, which FFmpeg logs.
Binding {
target: videoLoader.item
property: "mediaSource"
value: root.videoUrl
when: videoLoader.item !== null && Util.isVideoPath(root.path)
restoreMode: Binding.RestoreNone
}
Binding {
target: videoLoader.item
property: "playbackEnabled"
value: root.playbackEnabled
when: videoLoader.item !== null
}
Binding {
target: videoLoader.item
property: "audioEnabled"
value: root.audioEnabled
when: videoLoader.item !== null
}
Component { Component {
id: imageComponent id: imageComponent
-122
View File
@@ -1,122 +0,0 @@
import QtQuick
import QtMultimedia
// Deliberately a bare MediaPlayer and VideoOutput rather than the Video
// convenience type: Video always builds an AudioOutput, and a muted sink still
// decodes the audio stream and opens an audio client on every output.
Item {
id: root
property url mediaSource: ""
property bool playbackEnabled: true
property bool audioEnabled: false
property int mediaGeneration: 0
property bool priming: false
property int primingGeneration: -1
property bool frameReceived: false
readonly property bool ready: player.hasVideo
onMediaSourceChanged: {
mediaGeneration += 1
priming = false
primingGeneration = -1
frameReceived = false
primePauseTimer.stop()
framePauseTimer.stop()
output.clearOutput()
}
onPlaybackEnabledChanged: {
priming = false
frameReceived = false
primePauseTimer.stop()
framePauseTimer.stop()
if (playbackEnabled) player.play()
else player.pause()
}
function pauseAfterPrimedFrame() {
if (!priming
|| root.playbackEnabled
|| primingGeneration !== root.mediaGeneration) return
priming = false
primePauseTimer.stop()
framePauseTimer.stop()
player.pause()
}
// A paused MediaPlayer can load a source without presenting its first frame.
// Prime it until VideoOutput receives a frame, with a timeout so a stalled
// decoder cannot keep running indefinitely on battery.
Timer {
id: primePauseTimer
interval: 1000
repeat: false
onTriggered: root.pauseAfterPrimedFrame()
}
// Receiving a frame means the decoder has produced it, but the scene graph
// may not have committed it yet. Give VideoOutput a render cycle before
// pausing so the first frame is not lost on a source switch.
Timer {
id: framePauseTimer
interval: 50
repeat: false
onTriggered: root.pauseAfterPrimedFrame()
}
VideoOutput {
id: output
anchors.fill: parent
fillMode: VideoOutput.PreserveAspectCrop
}
// Sound is opted into per output: with a player per monitor, every output
// playing the track would layer copies of it. The sink is only built once
// the media reports a sound track, so a silent file never opens an audio
// client or its threads. Priming a paused player must not be heard.
Loader {
id: audioLoader
active: root.audioEnabled && player.hasAudio
sourceComponent: AudioOutput {
muted: root.priming || !root.playbackEnabled
}
}
MediaPlayer {
id: player
source: root.mediaSource
videoOutput: output
audioOutput: audioLoader.item
loops: MediaPlayer.Infinite
autoPlay: root.playbackEnabled
onMediaStatusChanged: {
if (mediaStatus !== MediaPlayer.LoadedMedia) return
if (!root.playbackEnabled) {
root.priming = true
root.primingGeneration = root.mediaGeneration
root.frameReceived = false
primePauseTimer.restart()
}
player.play()
}
}
Connections {
target: output.videoSink
function onVideoFrameChanged() {
if (player.mediaStatus !== MediaPlayer.BufferedMedia) return
if (!root.priming
|| root.playbackEnabled
|| root.primingGeneration !== root.mediaGeneration
|| root.frameReceived) return
root.frameReceived = true
framePauseTimer.restart()
}
}
}
-1
View File
@@ -4,7 +4,6 @@ BarIndicator 1.0 BarIndicator.qml
BarIconButton 1.0 BarIconButton.qml BarIconButton 1.0 BarIconButton.qml
BarWidget 1.0 BarWidget.qml BarWidget 1.0 BarWidget.qml
BackgroundMedia 1.0 BackgroundMedia.qml BackgroundMedia 1.0 BackgroundMedia.qml
BackgroundVideo 1.0 BackgroundVideo.qml
BorderOverlay 1.0 BorderOverlay.qml BorderOverlay 1.0 BorderOverlay.qml
BorderSurface 1.0 BorderSurface.qml BorderSurface 1.0 BorderSurface.qml
Button 1.0 Button.qml Button 1.0 Button.qml
+3 -40
View File
@@ -1,5 +1,4 @@
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Io import Quickshell.Io
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick import QtQuick
@@ -17,7 +16,6 @@ Item {
property string currentBackground: "" property string currentBackground: ""
property string displayedBackground: "" property string displayedBackground: ""
property int displayedReloads: 0
property string incomingBackground: "" property string incomingBackground: ""
property string oldBackground: "" property string oldBackground: ""
property bool finishingTransition: false property bool finishingTransition: false
@@ -28,23 +26,6 @@ Item {
property string pendingShellRaw: "" property string pendingShellRaw: ""
property real revealProgress: 1 property real revealProgress: 1
// Injected by the first-party service loader; used to reach the lock and idle
// services so playback can stop whenever nothing can see the wallpaper.
property var shell: null
// Stop a video wallpaper's decoding whenever it is covered. Qt's FFmpeg
// engine drives its own clock, so an unseen player keeps decoding until it
// is told not to — a locked laptop would otherwise decode until it died.
readonly property var lockService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.lock") : null
readonly property var idleService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.idle") : null
readonly property var batteryService: shell && shell.services ? shell.firstPartyServiceFor("omarchy.battery") : null
readonly property bool lockActive: lockService ? lockService.locked : false
readonly property bool screensaverActive: idleService ? idleService.screensaverWindowCount > 0 : false
readonly property bool powerSaverActive: batteryService ? batteryService.powerSaverOnBattery : false
// A lock or a screensaver covers every output, so it is decided once here.
// Fullscreen is decided per output below, because it only covers its own.
readonly property bool sessionObscured: lockActive || screensaverActive
function isVideo(path) { function isVideo(path) {
return Util.isVideoPath(path) return Util.isVideoPath(path)
} }
@@ -78,9 +59,6 @@ Item {
if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) { if (instant || !displayedBackground || isVideo(path) || isVideo(displayedBackground)) {
oldBackground = "" oldBackground = ""
incomingBackground = "" incomingBackground = ""
// A theme switch can replace the file behind an unchanged path, which
// an unchanged property would never pick up.
if (displayedBackground === finalPath) displayedReloads += 1
displayedBackground = finalPath displayedBackground = finalPath
revealProgress = 1 revealProgress = 1
return return
@@ -225,23 +203,9 @@ 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
// Pausing every wallpaper for one fullscreen window would freeze the one
// still on show next to it, which costs a viewer more than it saves. The
// workspace on show here knows whether a fullscreen window covers it,
// wherever focus happens to be.
readonly property var hyprlandMonitor: Hyprland.monitorFor(modelData)
readonly property var visibleWorkspace: hyprlandMonitor ? hyprlandMonitor.activeWorkspace : null
readonly property bool fullscreenHere: visibleWorkspace ? visibleWorkspace.hasFullscreen : false
// A sound track plays from one output only, or every monitor would
// layer its own copy of it.
readonly property bool firstScreen: Quickshell.screens.length > 0
&& String(Quickshell.screens[0].name || "") === String(modelData.name || "")
property bool maskReady: false property bool maskReady: false
function maybeStartReveal() { function maybeStartReveal() {
@@ -259,13 +223,12 @@ Item {
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore exclusionMode: ExclusionMode.Ignore
// OWE owns video backgrounds. This layer draws stills, and stays empty
// behind a video so OWE's own layer shows through.
BackgroundMedia { BackgroundMedia {
id: base id: base
anchors.fill: parent anchors.fill: parent
path: root.displayedBackground path: root.displayedBackground
reloads: root.displayedReloads
playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere
audioEnabled: panel.firstScreen
onReadyChanged: { onReadyChanged: {
if (ready && root.finishingTransition) { if (ready && root.finishingTransition) {
root.incomingBackground = "" root.incomingBackground = ""
+7
View File
@@ -0,0 +1,7 @@
import QtQuick
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 {
}
+21 -7
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
@@ -43,6 +44,9 @@ Item {
? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha") ? Border.surfaceSpec("lock", "border-error", Color.lock.borderError, root.outlineThickness, "border-alpha")
: Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha") : Border.surfaceSpec("lock", "border-active", Color.lock.borderActive, root.outlineThickness, "border-alpha")
readonly property bool video: Util.isVideoPath(root.backgroundPath)
readonly property bool feedActive: root.video && root.loadBackground && !root.displaysBlank && !root.powerSaverActive
signal submitPassword(string password) signal submitPassword(string password)
signal passwordTextEdited(string password) signal passwordTextEdited(string password)
signal clearFailureRequested() signal clearFailureRequested()
@@ -88,16 +92,15 @@ Item {
BackgroundMedia { BackgroundMedia {
id: wallpaper id: wallpaper
objectName: "lockWallpaper"
anchors.fill: parent anchors.fill: parent
path: root.loadBackground ? root.backgroundPath : "" path: root.loadBackground ? (root.video ? root.videoPosterPath : root.backgroundPath) : ""
version: root.backgroundVersion version: root.backgroundVersion
playbackEnabled: root.loadBackground && !root.displaysBlank && !root.powerSaverActive
} }
MultiEffect { MultiEffect {
anchors.fill: wallpaper anchors.fill: wallpaper
source: wallpaper.video ? null : wallpaper source: wallpaper
visible: !wallpaper.video
autoPaddingEnabled: false autoPaddingEnabled: false
blurEnabled: root.loadBackground && wallpaper.ready blurEnabled: root.loadBackground && wallpaper.ready
blur: 1.0 blur: 1.0
@@ -106,11 +109,22 @@ Item {
contrast: -0.08 contrast: -0.08
} }
// Qt's video output cannot be sampled by MultiEffect on every renderer. // 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. // Keep video wallpapers visible and darken them slightly for legibility.
Rectangle { Rectangle {
anchors.fill: wallpaper anchors.fill: feedLoader
visible: wallpaper.video visible: root.video
color: "#22000000" color: "#22000000"
} }
+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"
+49 -76
View File
@@ -9,15 +9,13 @@ const fs = require('fs')
const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8') const utilQml = fs.readFileSync(path.join(root, 'shell/Commons/Util.qml'), 'utf8')
const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8') const mediaQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundMedia.qml'), 'utf8')
const videoQml = fs.readFileSync(path.join(root, 'shell/Ui/BackgroundVideo.qml'), 'utf8')
const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8') const backgroundQml = fs.readFileSync(path.join(root, 'shell/plugins/background/Background.qml'), 'utf8')
const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8') const lockQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8')
const lockFeedQml = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockFeedSurface.qml'), 'utf8')
const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8') const themeSwitcher = fs.readFileSync(path.join(root, 'bin/omarchy-theme-switcher'), 'utf8')
const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8') const quattroUpgrade = fs.readFileSync(path.join(root, 'bin/omarchy-upgrade-to-quattro'), 'utf8')
const multimediaMigration = fs.readFileSync(path.join(root, 'migrations/1786609204.sh'), 'utf8')
const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8') const barTextColor = fs.readFileSync(path.join(root, 'bin/omarchy-bar-text-color'), 'utf8')
const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8') const menuImages = fs.readFileSync(path.join(root, 'bin/omarchy-menu-images'), 'utf8')
const lockView = fs.readFileSync(path.join(root, 'shell/plugins/lock/LockView.qml'), 'utf8')
const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8') const lockService = fs.readFileSync(path.join(root, 'shell/plugins/lock/Service.qml'), 'utf8')
const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8') const batteryService = fs.readFileSync(path.join(root, 'shell/plugins/services/battery/Service.qml'), 'utf8')
const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8') const themeSet = fs.readFileSync(path.join(root, 'bin/omarchy-theme-set'), 'utf8')
@@ -29,54 +27,43 @@ assert(
'shared media helper identifies video paths without truncating valid local names' 'shared media helper identifies video paths without truncating valid local names'
) )
assert( assert(
videoQml.includes('loops: MediaPlayer.Infinite') && !fs.existsSync(path.join(root, 'shell/Ui/BackgroundVideo.qml')),
videoQml.includes('autoPlay: root.playbackEnabled') && 'the shell keeps no video player of its own'
videoQml.includes('fillMode: VideoOutput.PreserveAspectCrop') &&
/imageUrl: path && !Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) &&
/videoUrl: path && Util\.isVideoPath\(path\) \? Util\.fileUrl\(path\) : ""/.test(mediaQml),
'background media plays aspect-cropped videos on a loop, and hands each loader only its own kind of file'
) )
assert( assert(
videoQml.includes('MediaPlayer.LoadedMedia') && /imageUrl: path && !video \? Util\.fileUrl\(path\) \+ \(version \? "\?v=" \+ version : ""\) : ""/.test(mediaQml) &&
videoQml.includes('primePauseTimer') && /active: root\.path !== "" && !root\.video/.test(mediaQml) &&
videoQml.includes('mediaGeneration') && !mediaQml.includes('videoUrl') &&
videoQml.includes('videoSink') && !mediaQml.includes('videoLoader') &&
videoQml.includes('onVideoFrameChanged') && !mediaQml.includes('videoEnabled'),
videoQml.includes('MediaPlayer.BufferedMedia') && 'background media is image-only: it loads stills and leaves videos to OWE'
videoQml.includes('mediaStatus !== MediaPlayer.BufferedMedia') &&
videoQml.includes('interval: 1000') &&
videoQml.includes('interval: 50') &&
videoQml.includes('frameReceived') &&
videoQml.includes('output.clearOutput()') &&
!videoQml.includes('KeepLastFrame') &&
/onPlaybackEnabledChanged:[\s\S]*?if \(playbackEnabled\) player\.play\(\)[\s\S]*?else player\.pause\(\)/.test(videoQml) &&
videoQml.includes('primingGeneration') &&
videoQml.includes('player.play()') &&
videoQml.includes('player.pause()'),
'paused video sources are primed to display their first frame'
) )
assert( assert(
!/^\s*import QtMultimedia/m.test(mediaQml) && !/^\s*import QtMultimedia/m.test(mediaQml) &&
mediaQml.includes('source: "BackgroundVideo.qml"'), !/^\s*import QtMultimedia/m.test(backgroundQml) &&
'the still-image path never imports QtMultimedia, so image-only sessions do not map it' !/^\s*import QtMultimedia/m.test(lockQml) &&
!backgroundQml.includes('MediaPlayer') &&
!lockQml.includes('MediaPlayer'),
'no shell surface builds a Qt Multimedia pipeline'
) )
assert( assert(
!/^\s*Video\s*\{/m.test(videoQml) && /^import Owe\.LockFeed$/m.test(lockFeedQml) &&
/property bool audioEnabled: false/.test(videoQml) && lockFeedQml.includes('LockFeed {') &&
/property bool audioEnabled: false/.test(mediaQml) && lockQml.includes('source: "LockFeedSurface.qml"') &&
/active: root\.audioEnabled && player\.hasAudio/.test(videoQml) && lockQml.includes('active: root.feedActive') &&
/audioOutput: audioLoader\.item/.test(videoQml) && /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml),
/muted: root\.priming \|\| !root\.playbackEnabled/.test(videoQml) && 'the lock screen shows video through the OWE lock feed, loaded so a missing module costs only the video'
/property: "audioEnabled"\s*\n\s*value: root\.audioEnabled/.test(mediaQml) &&
/firstScreen: Quickshell\.screens\.length > 0\s*\n\s*&& String\(Quickshell\.screens\[0\]\.name/.test(backgroundQml) &&
backgroundQml.includes('audioEnabled: panel.firstScreen') &&
!lockQml.includes('audioEnabled'),
'a sound track plays from the first monitor only, a silent file builds no audio output, and the lock stays quiet'
) )
assert( assert(
/property: "mediaSource"[\s\S]*?when: videoLoader\.item !== null && Util\.isVideoPath\(root\.path\)\s*\n\s*restoreMode: Binding\.RestoreNone/.test(mediaQml) && !/^import Owe\./m.test(lockQml),
!videoQml.includes('Component.onDestruction'), 'the lock view itself carries no foreign import, so the lock still loads without the feed module'
'a player on its way out keeps its source, so nothing is left loading for its destructor to cancel' )
assert(
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'
) )
assert( assert(
!mediaQml.includes('mipmap'), !mediaQml.includes('mipmap'),
@@ -86,33 +73,21 @@ assert(
/instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml), /instant \|\| !displayedBackground \|\| isVideo\(path\) \|\| isVideo\(displayedBackground\)[\s\S]*displayedBackground = finalPath/.test(backgroundQml),
'video switches bypass the image-only reveal stack and use the durable background path' 'video switches bypass the image-only reveal stack and use the durable background path'
) )
assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share video-capable media rendering') assert(backgroundQml.includes('BackgroundMedia {') && lockQml.includes('BackgroundMedia {'), 'desktop and lock screen share still rendering')
assert( assert(
lockQml.includes('source: wallpaper.video ? null : wallpaper') && !backgroundQml.includes('videoEnabled') &&
lockQml.includes('visible: !wallpaper.video') && !backgroundQml.includes('displayedReloads') &&
lockQml.includes('visible: wallpaper.video'), !mediaQml.includes('displayedReloads'),
'lock screen bypasses its image effect for video output' 'the desktop hands video backgrounds to OWE with no player switch to carry'
) )
assert( assert(
/sessionObscured:\s*lockActive \|\| screensaverActive/.test(backgroundQml) && !backgroundQml.includes('playbackEnabled') &&
backgroundQml.includes('playbackEnabled: !root.sessionObscured && !root.powerSaverActive && !panel.fullscreenHere') && !backgroundQml.includes('sessionObscured') &&
backgroundQml.includes('omarchy.lock') && !backgroundQml.includes('fullscreenHere') &&
backgroundQml.includes('omarchy.idle') && !backgroundQml.includes('Hyprland.monitorFor') &&
backgroundQml.includes('omarchy.battery'), !backgroundQml.includes('omarchy.lock') &&
'desktop playback stops while covered or on battery power-saver' !backgroundQml.includes('omarchy.battery'),
) 'the desktop no longer carries the shell video pause policy'
assert(
backgroundQml.includes('Hyprland.monitorFor(modelData)') &&
/fullscreenHere: visibleWorkspace \? visibleWorkspace\.hasFullscreen : false/.test(backgroundQml) &&
!backgroundQml.includes('ToplevelManager.activeToplevel'),
'a fullscreen window pauses only the output it covers, wherever focus is'
)
assert(
/if \(displayedBackground === finalPath\) displayedReloads \+= 1/.test(backgroundQml) &&
backgroundQml.includes('reloads: root.displayedReloads') &&
/active: root\.path !== "" && root\.video && !root\.reloading/.test(mediaQml) &&
/onReloadsChanged: \{[\s\S]*?reloading = true/.test(mediaQml),
'a theme switch that keeps the video path still reopens the replaced file'
) )
assert( assert(
barTextColor.includes('magick "$background_path[0]"'), barTextColor.includes('magick "$background_path[0]"'),
@@ -163,13 +138,12 @@ assert(
'a locked video wallpaper follows what each panel actually did, not only what the lock asked for' 'a locked video wallpaper follows what each panel actually did, not only what the lock asked for'
) )
assert( assert(
lockView.includes('playbackEnabled: root.loadBackground && !root.displaysBlank') && /feedActive: root\.video && root\.loadBackground && !root\.displaysBlank && !root\.powerSaverActive/.test(lockQml) &&
lockView.includes('&& !root.powerSaverActive') &&
/displaysBlank: root\.screenBlank\(/.test(lockService) && /displaysBlank: root\.screenBlank\(/.test(lockService) &&
/powerSaverActive: root\.powerSaverActive/.test(lockService) && /powerSaverActive: root\.powerSaverActive/.test(lockService) &&
/function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) && /function runBlank\(\) \{\s*\n\s*root\.displaysBlank = true/.test(lockService) &&
/function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService), /function runWake\(\) \{\s*\n\s*root\.displaysBlank = false/.test(lockService),
'the lock screen stops playback once displays go dark or power-saver is active' 'the lock feed stops once displays go dark or power-saver is active'
) )
assert( assert(
batteryService.includes('property string activePowerProfile') && batteryService.includes('property string activePowerProfile') &&
@@ -186,10 +160,6 @@ assert(
'theme switcher previews video-only themes, named preview files included' 'theme switcher previews video-only themes, named preview files included'
) )
assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background') assert(quattroUpgrade.includes("-iname '*.mp4'"), 'Quattro upgrade can seed a video-only theme background')
assert(
multimediaMigration.includes('omarchy-pkg-add qt6-multimedia qt6-multimedia-ffmpeg'),
'existing Quattro installations receive video playback dependencies'
)
JS JS
test_tmp=$(mktemp -d) test_tmp=$(mktemp -d)
@@ -303,8 +273,11 @@ timeout_rows=$(PATH="$test_tmp/bin:$PATH" XDG_CACHE_HOME="$timeout_cache" \
timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit) timeout_marker=$(find "$timeout_cache/omarchy/image-selector" -maxdepth 1 -type f -name '*.failed' -print -quit)
[[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed" [[ -z $timeout_marker ]] || fail "a timed out video is left to retry rather than remembered as failed"
grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia runtime is a base package" grep -qx 'owe' "$ROOT/install/omarchy-base.packages" || fail "OWE is a base package"
grep -qx 'qt6-multimedia-ffmpeg' "$ROOT/install/omarchy-base.packages" || fail "Qt Multimedia FFmpeg backend is a base package" grep -qx 'owe-lockfeed' "$ROOT/install/omarchy-base.packages" || fail "the OWE lock feed module is a base package"
if grep -qx 'qt6-multimedia' "$ROOT/install/omarchy-base.packages"; then
fail "Qt Multimedia is no longer needed by the shell"
fi
pass "menu image generator creates thumbnails consumed by the picker" pass "menu image generator creates thumbnails consumed by the picker"
pass "direct picker generates and reuses still thumbnails" pass "direct picker generates and reuses still thumbnails"
@@ -312,7 +285,7 @@ pass "direct picker omits videos whose thumbnails cannot be generated"
pass "a rejected video is remembered so it costs nothing on the next open" pass "a rejected video is remembered so it costs nothing on the next open"
pass "a timed out video is left to retry" pass "a timed out video is left to retry"
pass "a locked video wallpaper follows the panels' real DPMS state" pass "a locked video wallpaper follows the panels' real DPMS state"
pass "Qt Multimedia playback dependencies are declared" pass "OWE and its lock feed module are declared"
source <(awk ' source <(awk '
/^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 } /^(is_video_path|snapshot_background_path|background_transition_uses_snapshots|choose_theme_background|choose_staged_theme_background|set_theme_background)\(\) \{/ { copying=1 }