An animated desktop wallpaper for macOS. Menu-bar only, sandboxed, no third-party dependencies, ~800 lines of Swift you can read in one sitting.
Built because the alternatives are either paid apps or a 258 KB Objective-C++
codebase from a stranger — including a single 116 KB source file, a separate
daemon process and a CMake build — that is not realistically auditable by eye.
The underlying technique is small: a borderless NSWindow per display sitting
just above the system wallpaper, containing an AVPlayerLayer. Everything needed
ships with macOS.
Power is the design constraint, not an afterthought. The principle is don't draw, not decode efficiently. See Battery below.
macOS 26 (Tahoe) or later, Apple Silicon, Xcode command line tools. Developed and measured on macOS 26.5.2, M3 Pro, Swift 6.3.3.
./build.sh && open LiveWallpaper.appbuild.sh compiles Sources/*.swift with swiftc, assembles the .app, and
ad-hoc signs it with the sandbox entitlements. Signing is the last step — any
modification to the bundle after signing silently invalidates the signature, and
an invalid signature means the entitlements are not applied.
There is no .xcodeproj. Adding a video is done through the menu.
To quit, use the menu-bar item. Quitting removes the windows and the normal system wallpaper reappears.
LiveWallpaper
──────────────────────────
✓ Aurora.mp4 ← library, radio-select
Clouds.mp4
──────────────────────────
Add Video… ← copies into the app container
Remove Current from Library
──────────────────────────
Pause ← manual override; dims the menu-bar icon
Animate on ▸ ← per-display on/off
✓ Freeze in Low Power Mode
──────────────────────────
Library: 184 MB
Quit LiveWallpaper
The app requests no permissions at all and has no network access.
Exactly two entitlements:
| Entitlement | Why |
|---|---|
com.apple.security.app-sandbox |
On. |
com.apple.security.files.user-selected.read-only |
So "Add Video…" can read the file you pick. |
com.apple.security.network.client is deliberately omitted, which means
outbound networking is denied at the syscall level rather than merely unused.
This was verified rather than assumed — see
docs/verification.md, assumption A5, where the same
binary signed without entitlements was used as a control and every denied
operation flipped to allowed.
The occlusion heuristic reads only kCGWindowBounds, kCGWindowLayer and
kCGWindowAlpha. kCGWindowName is the key that would require Screen Recording
permission, and it is never touched.
Videos are copied into the app's own container at
~/Library/Containers/local.dmitriibaranov.LiveWallpaper/Data/Library/Application Support/Wallpapers/.
A sandboxed app has unrestricted access there and nowhere else, so this avoids
security-scoped bookmarks entirely — those are tied to the code-signing identity,
and an ad-hoc signature provides no dependable stable identity across rebuilds.
The cost is that videos are duplicated on disk; the menu shows total library size
and "Remove from Library" deletes the copy.
Bundle id local.dmitriibaranov.LiveWallpaper must stay stable, since the
container path derives from it.
The wallpaper freezes whenever drawing it would be wasted. When frozen the window stays on screen showing its last decoded frame, so revealing the desktop again has no black flash, and playback resumes from exactly where it stopped.
VisibilityMonitor collapses these into one boolean per display:
| Signal | Source |
|---|---|
| Window occluded | NSWindow.occlusionState, per window |
| Mostly covered | CGWindowListCopyWindowInfo coverage union, freeze at ≥90% |
| Space changed | NSWorkspace.activeSpaceDidChangeNotification |
| Display asleep / inactive | CGDisplayIsAsleep, CGDisplayIsActive |
| Screens slept | NSWorkspace.screensDidSleep/WakeNotification |
| System sleep | NSWorkspace.willSleep/didWakeNotification |
| Fast user switch | NSWorkspace.sessionDidResignActive/BecomeActiveNotification |
| Low Power Mode | ProcessInfo.isLowPowerModeEnabled |
| Disabled by user | per-display menu toggle, or global Pause |
Two things worth calling out:
Occlusion alone is not enough. occlusionState reports .visible if any
part of the window is visible, so a browser covering 95% of the screen would
still have us decoding and recompositing a full 5120×2880 frame to light up a 5%
sliver. The coverage heuristic is the backstop, and it fires often in practice.
Low Power Mode is the one exception to "keep animating on battery." Being unplugged is not a request to conserve; enabling Low Power Mode is. Default is to freeze, with a menu toggle to override.
Evaluation is event-driven. There is no permanent timer. A slow 3 s backstop poll runs only while a display sits above 60% coverage — the case where a window could be moved without generating any notification — and switches itself off otherwise. Its tolerance is half its interval so the system can coalesce the wakeup.
- Freeze when not visible. Takes cost to zero in the common laptop case.
- 24–30 fps source, not 60. Cuts decode and recomposite roughly proportionally.
- Fewer animating displays. Per-display toggle.
- Modest asset resolution (≤2560×1440). GPU upscale is nearly free; decode and memory bandwidth are not.
- SDR 8-bit, no audio track. HDR/10-bit on an XDR panel is the expensive mistake.
MacBook Pro M3 Pro, macOS 26.5.2, on battery, 60-second samples, unprivileged
(tools/measure.py). Raw rows in docs/measurements.tsv.
| State | Battery draw | WindowServer CPU | LiveWallpaper CPU |
|---|---|---|---|
| Baseline — app not running | 11.54 ± 0.00 W | 2.50% | — |
| Desktop visible, animating 30 fps | 14.83 ± 2.19 W | 31.56% | 5.33% |
| Manually paused | 11.23 ± 0.02 W | 2.60% | 0.00% |
Freezing works. Paused draws 11.23 W against a baseline of 11.54 W and moves WindowServer by 0.1 percentage points (2.60% vs 2.50%). Paused actually reads below baseline, which is drift in background activity rather than a real saving — the honest statement is that a frozen wallpaper is indistinguishable from not running the app at all. This is the plan's central premise (assumption A6) and it passes, so the fallback of hiding the window entirely was not needed.
Compositing is the whole bill, not decode. Animating costs +29 percentage points of WindowServer CPU but only 5.33% inside our own process. Hardware decode on the M3 Pro media engine is nearly free; putting the frames on screen is not. That asymmetry is exactly why the design principle is don't draw rather than decode efficiently, and why the per-display toggle matters more than any codec choice.
Cost when it is actually visible: +3.3 W, or about 29% on top of this machine's idle draw. That is a real cost, and it is the reason the gating exists rather than being a nicety.
- Not built-in-display-only. All three displays were connected. The U28E590 was frozen behind a fullscreen app for the whole session, so the animating row is two displays, not one and not three.
- Not a clean room. Docker, IntelliJ, Slack and Chrome were running, which is why the baseline is 11.5 W and why the animating row has a ±2.19 W spread. The paused-versus-baseline comparison is unaffected, since both were stable (±0.00 and ±0.02).
- Coarse.
InstantAmperageupdates every few seconds; the baseline returned an identical value on all 40 samples. WindowServer CPU is the finer signal here. - 30 fps versus 60 fps was not measured. Lever 2 remains reasoned, not verified. A matched 60 fps encode of the same clip is in the library if you want to run it.
- Automatic gating was not power-measured, only manual pause. The two share the same code path, and automatic freezing was separately observed to take the process to 0.0% CPU.
Target: ≤2560×1440, 30 fps, SDR 8-bit (yuv420p), H.264 or HEVC, no audio.
ffmpeg -i input.mov \
-vf "scale='min(2560,iw)':-2:flags=lanczos,format=yuv420p" \
-r 30 -an \
-c:v libx264 -profile:v high -crf 21 -movflags +faststart \
wallpaper.mp4format=yuv420pforces SDR 8-bit — the single most important flag here.-r 30halves the work versus a 60 fps source.-androps the audio track. The player is muted regardless, but an audio track still costs a decode.scale='min(2560,iw)':-2downscales only if the source is larger, and keeps the height even.
For HEVC with hardware encoding, swap in -c:v hevc_videotoolbox -q:v 55 -tag:v hvc1.
avconvert ships with macOS and needs nothing installed, but it cannot change
frame rate, cannot remove an audio track, and has no preset between 1920×1080 and
3840×2160. It is only useful for the resolution and codec step:
avconvert --source input.mov --output wallpaper.mov \
--preset PresetHEVC1920x1080 --replaceIf your source is already 30 fps or less and you do not mind a muted audio track
riding along, that is sufficient. Otherwise use the ffmpeg recipe — levers 2 and 5
are not reachable with avconvert.
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height,r_frame_rate,pix_fmt,codec_name \
-of csv=p=0 wallpaper.mp4
ffprobe -v error -select_streams a -show_entries stream=index -of csv=p=0 wallpaper.mp4The second command should print nothing — that means no audio track.
| File | Lines | Job |
|---|---|---|
main.swift |
9 | NSApplication bootstrap, .accessory activation policy |
Log.swift |
14 | os.Logger categories |
AppDelegate.swift |
47 | Wires the pieces together, teardown on quit |
Settings.swift |
71 | UserDefaults wrapper |
PlaybackController.swift |
74 | One player per display; play / freeze |
WallpaperLibrary.swift |
102 | Container-backed video library |
WallpaperWindow.swift |
129 | NSWindow subclass + AVPlayerLayer view |
DisplayCoordinator.swift |
208 | Screen enumeration, mirror dedup, window reconciliation |
MenuBarController.swift |
234 | NSStatusItem + menu |
VisibilityMonitor.swift |
309 | Decides whether each display should animate |
VisibilityMonitor is the only type that knows why playback should stop;
PlaybackController only knows whether to run. That boundary is what keeps the
battery logic changeable without touching rendering.
window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)Measured level map on macOS 26.5.2:
desktop-3 WindowServer desktop backing (one per display)
desktop-2 Apple's "Wallpaper" process
desktop-1 Dock
desktop+1 ← us
desktop+20 Finder desktop icons / WindowManager (== .desktopIconWindow)
desktop+22 Notification Center desktop widgets
Desktop icons, clicks, drag-selection, click-to-reveal-desktop, widgets and Stage Manager are all unaffected — verified by hand, see docs/verification.md.
AVQueuePlayer + AVPlayerLooper per display, muted. AVPlayerLooper rather
than seeking to zero on AVPlayerItemDidPlayToEndTime, which produces a visible
hitch at every loop.
One player per display rather than one shared decode fanned out to several
layers. Sharing would mean AVPlayerItemVideoOutput → manual CVPixelBuffer →
hand-rolled frame pacing, leaving AVFoundation's zero-copy path. Three hardware
decodes on an M3 Pro media engine are not the bottleneck; the compositor is.
Two settings that exist purely for battery and are easy to miss:
player.preventsDisplaySleepDuringVideoPlayback = false // or it holds a wake assertion
player.automaticallyWaitsToMinimizeStalling = false // local file, nothing to buffer- Lock-screen wallpaper. No public API; would require reverse-engineering the lock-screen layer. The wallpaper covers the desktop only.
- GIF / HTML / WebGL wallpapers. CPU-decoded or JS-driven; worse for battery and a much larger attack surface.
- Per-display different videos. One video everywhere. Per-display on/off is supported.
- Launch at login, audio, notarization, App Store distribution.
- A separate daemon process. A single
LSUIElementapp owns its windows and exits cleanly.
/usr/bin/log stream --predicate 'subsystem == "local.dmitriibaranov.LiveWallpaper"'
/usr/bin/log show --predicate 'subsystem == "local.dmitriibaranov.LiveWallpaper"' --last 10mUse the absolute path — zsh has a log builtin that shadows it.
Categories: app, display, playback, visibility, library. Every gating
decision is logged with its reason and trigger, e.g.
display 1 -> FREEZE (occl=visible but covered 97%) [NSWorkspaceDidActivateApplicationNotification]
display 2 -> ANIMATE (occl=visible, covered 0%) [NSWorkspaceActiveSpaceDidChangeNotification]
python3 tools/measure.py "desktop visible, animating" --seconds 60 --delay 15Works with or without root:
| Without sudo | With sudo | |
|---|---|---|
| Battery discharge (W) | ✅ | ✅ |
| LiveWallpaper CPU % | ✅ | ✅ |
| WindowServer CPU % | ✅ | ✅ |
| CPU / GPU package power (mW) | — | ✅ |
powermetrics genuinely needs root — it reads privileged SMC counters. But
battery discharge, which is the ground truth here (CPU and GPU counters alone
miss media-engine, DRAM and display power), does not. WindowServer CPU % is the
most directly relevant unprivileged number, since compositing rather than
decode is what this project is trying to avoid.
Without sudo, use longer samples (--seconds 60) — InstantAmperage updates
only every few seconds, so the discharge figure is coarser than powermetrics.
--delay N pauses before sampling so you can hide the terminal (⌘H) or set up
the screen state being measured; otherwise the terminal window itself covers the
desktop and the wallpaper freezes, and you measure the wrong thing. Each run
prints the gating verdict per display so you can confirm it measured what it
claims. Results append to docs/measurements.tsv.
Measurements must be on battery for the discharge figure to mean anything, and are most meaningful with heavy background apps quit.
rm -rf LiveWallpaper.app
rm -rf ~/Library/Containers/local.dmitriibaranov.LiveWallpaperThe second line deletes the copied videos and settings.