Skip to content

feat: lock screen video wallpaper via Apple Aerial injection#79

Draft
CagesThrottleUs wants to merge 20 commits intothusvill:SwiftUIfrom
CagesThrottleUs:cages/implement-lock-screen-video-feature
Draft

feat: lock screen video wallpaper via Apple Aerial injection#79
CagesThrottleUs wants to merge 20 commits intothusvill:SwiftUIfrom
CagesThrottleUs:cages/implement-lock-screen-video-feature

Conversation

@CagesThrottleUs
Copy link
Copy Markdown

Summary

  • Adds full-motion video wallpaper support for the macOS lock screen using Apple's native Aerial system
  • Injects a two-level symlink chain (system slot → user cache → video) so subsequent wallpaper changes require no admin access after the first setup
  • Daemon saves playback position at lock time and resumes at the correct timestamp after unlock with a 0.4s fade-in (no WallpaperVideoExtension kill)
  • New Lock Screen Video 🔒 toggle in Settings with error feedback

How it works

  1. One-time privileged setup — on first enable, a bash+Python3 script runs via NSAppleScript with admin privileges to:
    • Create the system slot symlink at /Library/Application Support/com.apple.idleassetsd/Customer/4KSDR240FPS/lw_slot.mov
    • Append livewallpaper_custom_001 to entries.json (idempotent)
  2. Subsequent changesupdateUserSymlink: updates ~/Library/Caches/com.thusvill.LiveWallpaper/lockscreen/current.mov without any admin prompt
  3. Index.plistwriteIndexPlist: sets AllSpacesAndDisplays.Idle.Content.Choices to select the custom asset as the active lock screen
  4. Timing syncscreenLocked: snapshots CMTime + CFAbsoluteTime; screenUnlocked: seeks via completionHandler: then fades windows in

Files changed

File Change
LockScreenAerialManager.h / .mm New Obj-C singleton — symlink chain, entries.json, Index.plist, privileged setup
LiveWallpaper-Bridging-Header.h Import for Swift visibility
LiveWallpaper.xcodeproj/project.pbxproj Register new source files
wallpaperdaemon/daemon.mm Timing snapshot at lock + seek-on-unlock + 0.4s fade-in
ContentView.swift Lock Screen Video toggle in Settings with error/subtitle display
WallpaperEngine.mm Hook to update user symlink on wallpaper change when toggle is on
en.lproj/Localizable.strings New lock screen strings
README.md Lock Screen Video section with setup steps and format requirements

Test plan

⚠️ Unable to test locally — no Apple ID available to install Xcode. Please verify on a Mac with Xcode installed.

  • Build succeeds with no errors or warnings
  • Select a .mov or .mp4 wallpaper and enable Lock Screen Video 🔒 in Settings
  • Admin password prompt appears on first enable
  • Verify symlinks: ls -la "/Library/Application Support/com.apple.idleassetsd/Customer/4KSDR240FPS/lw_slot.mov" and ls -la ~/Library/Caches/com.thusvill.LiveWallpaper/lockscreen/current.mov
  • Lock screen plays video (⌘⌃Q to lock)
  • Unlock resumes desktop video at correct timestamp with smooth fade-in
  • Change wallpaper while toggle is on — lock screen updates without another admin prompt
  • Disable toggle — lock screen reverts to default, no error shown
  • Enable toggle with no active wallpaper — "No wallpaper is currently active." error shown
  • Re-lock after disable — default macOS lock screen restored

🤖 Generated with Claude Code

Introduces LockScreenAerialManager Obj-C singleton (Task 1 of 5):
- isVideoCodecSupported: validates .mov always, .mp4 only for H.264/HEVC
- updateUserSymlink: creates user-level symlink with no admin rights
- writeIndexPlist / revertIndexPlist: patches com.apple.wallpaper Index.plist
  to select livewallpaper_custom_001 and saves previous state for rollback
- performPrivilegedSetupWithVideoPath:completion: stub — implemented in Task 2
Wired into Xcode project (PBXBuildFile, PBXFileReference, group + Sources phase)
and exposed to Swift via LiveWallpaper-Bridging-Header.h.
- Add nil guards to revertIndexPlist: to prevent crash on missing/changed plist keys
- Log symlink creation errors in updateUserSymlink: instead of silently discarding
- Move kEntriesJSON to its usage site (Task 2 comment area), remove top-level constant
- Wrap debug NSLog in writeIndexPlist: with #ifdef DEBUG
- Add GPL-3 license header to both LockScreenAerialManager.h and .mm
…Script

Implements performPrivilegedSetupWithVideoPath:completion: in
LockScreenAerialManager.mm. Writes a bash+Python3 temp script that
creates the system symlink (lw_slot.mov) and idempotently patches
entries.json, then runs it via NSAppleScript with administrator
privileges. Calls updateUserSymlink: on success and always delivers
the completion block on the main thread.
- Promote entries.json path to file-level kEntriesJSON constant
- Use /tmp/lw_lockscreen_setup.sh instead of NSTemporaryDirectory() to avoid shell injection
- Guard NSAppleScriptErrorNumber against nil with -1 fallback
- Add python3 availability check in bash script body
- Derive cachePath/cacheDir/sysSlot/sysDir from self.userCachePath and kSystemSlot to eliminate duplicate path strings
durSecs == 0.0 passes CMTIME_IS_VALID but makes fmod(x, 0) undefined.
Wrap seek block in durSecs > 0.0 check.
…on unlock

screenLocked: bypassed pauseAllPlayers, leaving playbackPaused=NO.
resumeAllPlayers guards on that flag, causing video to stay frozen
until the 0.5s checkAndUpdatePlaybackState dispatch fired.
…ndler

- Guard _players.firstObject.currentItem before calling currentTime
  to avoid silent kCMTimeZero when players array is empty at lock time
- Use seekToTime:completionHandler: so resumeAllPlayers and fade-in
  fire only after the seek timebase has settled, preventing stutter
- Guard against empty currentVideoPath before calling LockScreenAerialManager
- Simplify subtitle display: remove no-op Optional() wrapper
- Call writeIndexPlist on fast path so Index.plist activates the asset
- Capture revertIndexPlist error and surface it to lockScreenVideoError
@thusvill
Copy link
Copy Markdown
Owner

thusvill commented Apr 24, 2026

It crashes when it call that writeIndexPlist function.We might need a different approach for that function.The crash basically cause the NSError pointer.

[edit]
Love the idea and i also searched a way to do this, glad to see that you have a good approach.I'll merge this if this really works.

Unfortunately I can't develop it because of my tight timetable , but I'll check and merge once you fix thoes errors

@CagesThrottleUs
Copy link
Copy Markdown
Author

@thusvill I am unfortunately not able to test it because of Apple ID issues - do you know of an alternate way for me to test it out locally, this would save time in development.

…ssetsd restart

writeIndexPlist crashed because NS_ASSUME_NONNULL_BEGIN made NSError**
nonnull, causing Swift to bridge it as throws — passing nil was undefined.

Fix:
- Mark both writeIndexPlist/revertIndexPlist with NS_SWIFT_NOTHROW and
  _Nullable * _Nullable so Swift treats them as regular BOOL functions
- Remove writeIndexPlist call from the enable flow (was fragile due to
  undocumented Index.plist key path that varies by macOS version)
- Add `killall idleassetsd` to the privileged setup script so the daemon
  restarts and picks up the new entries.json entry automatically
Signed-off-by: CagesThrottleUs <manstein.felix@gmail.com>
@thusvill
Copy link
Copy Markdown
Owner

@thusvill I am unfortunately not able to test it because of Apple ID issues - do you know of an alternate way for me to test it out locally, this would save time in development.

I can't understand why you can create a new AppleID?? 🤔.
However It build without errors now, but the lockscreen video not working.

@CagesThrottleUs CagesThrottleUs marked this pull request as draft April 24, 2026 12:54
@CagesThrottleUs
Copy link
Copy Markdown
Author

I was able to get a system where I can test it out still fixing errors.

@thusvill
Copy link
Copy Markdown
Owner

I was able to get a system where I can test it out still fixing errors.

Oh Thats Great 😊

@CagesThrottleUs
Copy link
Copy Markdown
Author

Hey — sorry for the noise on this issue. I spent a day trying to get lock screen video working and didn't fully crack it. Writing this up so the investigation isn't lost.


What I was trying to do

When the user presses Ctrl+Cmd+Q, play their chosen video on the lock screen immediately — not after 20 minutes of idle.

Short version

The file-replacement approach works perfectly when LiveWallpaper is quit. The problem is the running daemon calls NSWorkspace.setDesktopImageURL: which writes a static PNG provider back to Index.plist — and WallpaperAerialsExtension reads that and shows a still image instead of the video.


How the lock screen video system works on Tahoe

WallpaperAerialsExtension plays .mov files from:

~/Library/Application Support/com.apple.wallpaper/aerials/videos/{UUID}.mov

What it plays is controlled by the Idle key in ~/Library/Application Support/com.apple.wallpaper/Store/Index.plist. Provider com.apple.wallpaper.choice.aerials → aerial plays. Provider com.apple.wallpaper.choice.image → static PNG shows.


What actually works

Replace the .mov files in aerials/videos/ with the user's video (back up originals as .bak), then killall WallpaperAerialsExtension. The extension relaunches, picks up the new files, and plays the custom video on the lock screen.

This works reliably when LiveWallpaper is not running. Confirmed repeatedly.


Why it breaks when LiveWallpaper is running

wallpaperdaemon has setStaticWallpaper, which calls NSWorkspace.setDesktopImageURL: with a PNG thumbnail. That call overwrites both the Desktop and Idle entries in Index.plist, replacing the aerial provider with a static PNG.

setStaticWallpaper is called from SpaceChangeCallback — which fires every time a space/display changes, including when WallpaperAerialsExtension restarts after we kill it. So:

  1. We replace .mov files ✓
  2. We clear the Idle entry from Index.plist
  3. We kill WallpaperAerialsExtension
  4. Extension relaunches → triggers SpaceChangeCallback in the daemon
  5. SpaceChangeCallbacksetStaticWallpaper → PNG written back to Idle
  6. Extension reads the freshly-written PNG provider → shows still image ✗

Things I tried that didn't work

Screensaver idle time — Set com.apple.screensaver idleTime to 1 second. Irrelevant. Lock screen video on Tahoe is WallpaperAerialsExtension, not ScreenSaverEngine. Completely separate systems.

Kill app on lock, restart on unlockcom.apple.screenIsLocked fires after the lock screen is already rendered. Too late.

Guarding setStaticWallpaper — Added a check: if .bak files exist in aerials/videos/, skip the NSWorkspace.setDesktopImageURL: call. This stops the daemon from overwriting Index.plist. But WallpaperAerialsExtension maintains its own internal state and restores the Idle entry from its own cache on restart — independently of Index.plist. We saw it restore an older wallpaper (retro-futuristic-gaming-desktop.png) that hadn't been set recently. The extension has a source of truth we weren't reaching.

Hiding daemon windows on lock — Thought the daemon window (at kCGDesktopWindowLevel - 1) might be a frozen frame showing as the "static image". Tested by creating a bright green NSWindow at exactly that level. Visible on the desktop, not visible on the lock screen at all. Windows at that level are not composited into the lock screen background. The still image comes purely from Index.plist, not the daemon's render layer.

Clearing Index.plist Idle entries — Implemented clearIdleWallpaperEntries which deletes all Idle keys before killing the extension. Didn't hold — the extension restores the entry from its own store on reinit.


Next steps for whoever picks this up

The core problem is that WallpaperAerialsExtension has an internal data store that it treats as authoritative over Index.plist. Find that store:

# After killing the extension, see what files it touches when it restarts
touch /tmp/before && killall WallpaperAerialsExtension
sleep 3
find ~/Library/Application\ Support/com.apple.wallpaper -newer /tmp/before -type f

Or watch it live:

sudo fs_usage -w -f filesystem | grep -i "WallpaperAerials"

Once you know which file the extension reads as its Idle source of truth, write to that file (not just Index.plist) before restarting the extension. That should make the replacement stick even while LiveWallpaper is running.

A longer shot worth checking: WallpaperKit.framework (private, available on Tahoe) may have a proper API for setting the Idle provider programmatically. That would bypass the whole plist-vs-cache race entirely and is probably the "right" solution if it exists.

The guard in setStaticWallpaper (skip when .bak files exist) is still necessary regardless — keep that in place.


State of the branch

Branch: cages/implement-lock-screen-video-feature

  • LockScreenAerialManager.mm — file-replacement approach (backup as .bak, hard-link user video into every slot, clear Index.plist, kill extension). The logic is sound — keep it.
  • daemon.mmsetStaticWallpaper guard (necessary); window alpha=0 on lock (harmless, windows aren't visible on lock screen anyway)
  • ContentView.swift — removed dead screensaver idle-time manipulation code

The LockScreenAerialManager implementation is production-quality. The missing piece is entirely on the extension cache side.

Sorry for leaving this incomplete — hope it saves someone time.

@thusvill
Copy link
Copy Markdown
Owner

thusvill commented Apr 25, 2026

No, its my bad that i didn't help you.
But Im glad to see that you tried to crack it.

So according to your comment, what i understood is that setStaticWallpaper ruins the thing.So maybe if I block that function befor the lock screen and apply video to the plist, this would work.
I'll give it a try.

Thank you for your support.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants