Skip to content

Latest commit

 

History

9 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

LiveWallpaper

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.


Requirements

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 and run

./build.sh && open LiveWallpaper.app

build.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.

Menu

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

Permissions and sandboxing

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.


Battery

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.

Ranked levers

  1. Freeze when not visible. Takes cost to zero in the common laptop case.
  2. 24–30 fps source, not 60. Cuts decode and recomposite roughly proportionally.
  3. Fewer animating displays. Per-display toggle.
  4. Modest asset resolution (≤2560×1440). GPU upscale is nearly free; decode and memory bandwidth are not.
  5. SDR 8-bit, no audio track. HDR/10-bit on an XDR panel is the expensive mistake.

Measured

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.

What these numbers are not

  • 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. InstantAmperage updates 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.

Preparing wallpaper assets

Target: ≤2560×1440, 30 fps, SDR 8-bit (yuv420p), H.264 or HEVC, no audio.

With ffmpeg (complete)

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.mp4
  • format=yuv420p forces SDR 8-bit — the single most important flag here.
  • -r 30 halves the work versus a 60 fps source.
  • -an drops the audio track. The player is muted regardless, but an audio track still costs a decode.
  • scale='min(2560,iw)':-2 downscales 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.

With avconvert (system tool, partial)

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 --replace

If 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.

Checking what you produced

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.mp4

The second command should print nothing — that means no audio track.


Architecture

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.

The window

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.

Playback

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

Not included

  • 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 LSUIElement app owns its windows and exits cleanly.

Debugging

/usr/bin/log stream --predicate 'subsystem == "local.dmitriibaranov.LiveWallpaper"'
/usr/bin/log show   --predicate 'subsystem == "local.dmitriibaranov.LiveWallpaper"' --last 10m

Use 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]

Measuring power yourself

python3 tools/measure.py "desktop visible, animating" --seconds 60 --delay 15

Works 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.

Uninstall

rm -rf LiveWallpaper.app
rm -rf ~/Library/Containers/local.dmitriibaranov.LiveWallpaper

The second line deletes the copied videos and settings.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages