Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ Tests cover:
- `Xorshift64` — same seed → same sequence; different seeds → different sequences
- `NativeMatrixRenderer` — `seedOffset` defaults to `0`; two renderers with different `seedOffset` values diverge after 60 frames

## README audience

- Change `README.md` only when end-user information changes, such as installation, usage, or compatibility guidance.
- Keep implementation details and contributor reference in `docs/development.md`, plans in `docs/plans/`, and agent instructions in `AGENTS.md`.

## Code documentation

Use Swift doc comments (`///`) for all public and internal declarations — types, properties, methods, and parameters. Follow the standard Swift/Xcode documentation style:
Expand Down
33 changes: 4 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ brew trust --cask patrickschaper/tap/matrix-screen-saver
brew install --cask patrickschaper/tap/matrix-screen-saver && open "/Applications/Matrix Wallpaper.app"
```

- Wallpaper: open **Matrix Wallpaper** in Applications. options available through menubar icon.
- Wallpaper: open **Matrix Wallpaper** in Applications. Choose **Settings…** from its menu bar icon to customize the animation.
- Screen saver options: **System Settings → Wallpaper → Screen Saver → MatrixScreenSaver → Options…**.

## Buy me a coffee
Expand Down Expand Up @@ -71,6 +71,8 @@ The bundled [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/M

## Development

See the [project roadmap](docs/plans/2026-11-10-1812-project-roadmap-plan.md) for planned work, including GPU-accelerated 3D scenes.

### Current state

- Native Swift/AppKit screen saver bundle
Expand Down Expand Up @@ -179,34 +181,7 @@ To install and launch the wallpaper app in your user Applications folder:
open "$HOME/Applications/Matrix Wallpaper.app"
```

The installer builds and verifies the app, gracefully quits running copies before
replacement, and retains preferences. It does not launch the app or enable login startup.
It migrates a signed `MatrixWallpaper.app` with the matching bundle ID to
`Matrix Wallpaper.app`, restoring the previous installation if replacement fails.
Unrelated bundles and symlinks at the old path are left alone. The executable remains
`MatrixWallpaper` and the bundle ID remains `dev.patsch.MatrixWallpaper`.

When MatrixScreenSaver is installed in `~/Library/Screen Savers` or `/Library/Screen Savers`,
the wallpaper menu shows **Sync with Screen Saver**. It is checked by default: the saver
shares the wallpaper's animation. Uncheck it to let the saver animate independently.
The choice survives app restarts and saver reinstallation; opening the menu refreshes
installation detection. This does not change visual options or synchronization between monitors.

While Matrix Wallpaper runs, it saves each display's desktop image and display options,
then sets the underlying wallpaper to plain black. **Stop** and **Pause/Resume** share
one native menu row. **Pause** freezes the visible frame; **Resume** continues the scene.
Pausing is session-only and does not persist across launches. With **Sync with Screen Saver**
enabled, the saver temporarily advances the shared scene even while the wallpaper is paused.
When the saver exits, the wallpaper pauses again at the updated scene. **Stop** and
**Quit Matrix Wallpaper** restore the original wallpaper settings. Temporary pauses
and animation restarts keep the original backup.
If you choose a different wallpaper while Matrix runs, that newer choice is preserved.

Backups are stored in `~/Library/Application Support/MatrixWallpaper/DesktopWallpaper/`.
After a crash or force-quit, reopen Matrix Wallpaper and choose **Stop** to restore the desktop.
Disconnected displays and failed restores keep their backups for a later retry.
Exact restoration is supported for static images; macOS's public API cannot preserve every
dynamic/aerial wallpaper, rotating collection, or separate Space configuration.
See the [developer notes](docs/development.md) for installation internals, version checks, and wallpaper restoration details.

### Origin

Expand Down
Binary file added Resources/BuyMeACoffee.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified Resources/MenuBarIcon.pdf
Binary file not shown.
299 changes: 202 additions & 97 deletions Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift

Large diffs are not rendered by default.

57 changes: 54 additions & 3 deletions Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ final class MatrixScreenSaverView: ScreenSaverView {
/// Set before starting animation; ordinary saver and preview hosts keep the default.
var isWallpaperHost = false

/// Wallpaper supplies its host snapshot; the saver checks its own loaded bundle.
var readVersionStatus: () -> MatrixVersionStatus = {
MatrixVersionStatus.current(hostBundle: Bundle(for: MatrixScreenSaverView.self))
}
private var versionMismatch = false
private var nextVersionCheck: TimeInterval = 0
private var errorMotionTime: TimeInterval = 0

/// Lets the wallpaper's sole animation session continue while a saver covers its windows.
var rendersWallpaperWhileHidden = false

Expand Down Expand Up @@ -245,14 +253,24 @@ final class MatrixScreenSaverView: ScreenSaverView {
super.startAnimation()
animationActive = true
firstHiddenFrameTime = 0
versionMismatch = readVersionStatus().state == .mismatch
nextVersionCheck = ProcessInfo.processInfo.systemUptime + 60
updateScreenSaverLifecycleObservation()
if versionMismatch {
nativeRenderer.stop()
animationTimeInterval = 1.0 / 30.0
setAccessibilityLabel("Version mismatch. Install matching versions to play.")
needsDisplay = true
return
}
setAccessibilityLabel(nil)
if shouldReloadOptionsOnStart {
saverOptions = Self.loadSaverOptions()
}
shouldReloadOptionsOnStart = true
wallpaperFrameReader = !isWallpaperHost && !isPreview && Self.isRunningInScreenSaverEngine ? WallpaperFrameReader() : nil
NSLog("MatrixScreenSaver startAnimation bounds=%@", NSStringFromRect(bounds))
appendDebugLog("[\(debugIdentifier)] startAnimation bounds=\(NSStringFromRect(bounds))")
updateScreenSaverLifecycleObservation()
if let wallpaperFrameReader {
nativeRenderer.stop()
awaitingWallpaperUntil = ProcessInfo.processInfo.systemUptime + 1
Expand Down Expand Up @@ -328,6 +346,13 @@ final class MatrixScreenSaverView: ScreenSaverView {
}
firstHiddenFrameTime = 0

if ProcessInfo.processInfo.systemUptime >= nextVersionCheck { refreshVersionStatus() }
if versionMismatch {
if !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { errorMotionTime += animationTimeInterval }
needsDisplay = true
return
}

if let wallpaperFrameReader {
if let frame = wallpaperFrameReader.frame(displayID: UInt32(displaySeed), pointSize: bounds.size) {
awaitingWallpaperUntil = nil
Expand Down Expand Up @@ -437,6 +462,17 @@ final class MatrixScreenSaverView: ScreenSaverView {

/// Paints the full terminal-window scene for the current frame.
override func draw(_ dirtyRect: NSRect) {
if versionMismatch {
NSColor.black.setFill()
bounds.fill()
let size = min(72, min(bounds.width, bounds.height) * 0.2)
let reduced = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion
let x = reduced ? 0 : sin(errorMotionTime * 0.12) * max(0, bounds.width - size) * 0.4
let y = reduced ? 0 : sin(errorMotionTime * 0.09) * max(0, bounds.height - size) * 0.4
MatrixVersionStatus.warningImage?.draw(in: NSRect(x: bounds.midX + x - size / 2,
y: bounds.midY + y - size / 2, width: size, height: size))
return
}
if let image = sharedWallpaperImage ?? publishedWallpaperImage {
NSGraphicsContext.current?.cgContext.draw(image, in: bounds)
return
Expand Down Expand Up @@ -1259,7 +1295,7 @@ final class MatrixScreenSaverView: ScreenSaverView {
///
/// - Returns: A private shared-memory file, or nil if this host cannot publish it.
func shareWallpaperFrames() -> URL? {
guard isWallpaperHost, animationActive else { return nil }
guard isWallpaperHost, animationActive, !versionMismatch else { return nil }
let scale = window?.backingScaleFactor ?? 1
if sharedFrameBuffer?.pointSize != bounds.size || sharedFrameBuffer?.displayID != UInt32(displaySeed) ||
sharedFrameBuffer?.pixelWidth != Int(ceil(bounds.width * scale)) ||
Expand Down Expand Up @@ -1418,6 +1454,21 @@ final class MatrixScreenSaverView: ScreenSaverView {
super.startAnimation()
}

/// Stops incompatible rendering and frame sharing, or restarts after versions match.
func refreshVersionStatus() {
nextVersionCheck = ProcessInfo.processInfo.systemUptime + 60
let mismatch = readVersionStatus().state == .mismatch
guard mismatch != versionMismatch else { return }
versionMismatch = mismatch
if animationActive {
let wasPaused = wallpaperAnimationPaused
stopAnimation()
startAnimation()
if wasPaused { pauseWallpaperAnimation() }
}
needsDisplay = true
}

/// Applies options to the live saver and optionally persists or restarts it.
private func applySaverOptions(_ options: MatrixScreenSaverOptions, persist: Bool, restartAnimation: Bool) {
let sanitizedOptions = options.sanitized()
Expand Down Expand Up @@ -1454,7 +1505,7 @@ final class MatrixScreenSaverView: ScreenSaverView {

/// Creates a fresh configure-sheet controller for each presentation.
private func makeOptionsSheetController() -> MatrixScreenSaverOptionsSheetController {
let controller = MatrixScreenSaverOptionsSheetController { [weak self] options in
let controller = MatrixScreenSaverOptionsSheetController(readVersionStatus: readVersionStatus) { [weak self] options in
self?.applySaverOptionsFromSheet(options)
}
controller.onClose = { [weak self, weak controller] in
Expand Down
159 changes: 159 additions & 0 deletions Sources/MatrixScreenSaver/MatrixVersionStatus.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import AppKit

/// Compares the loaded component with discoverable companion installations, not another process's memory.
struct MatrixVersionStatus: Equatable {
/// Missing and unreadable metadata must not be presented as proven compatibility.
enum State {
case matching, mismatch, missingCompanion, unverified
}

/// Identifies a copy without assuming macOS has selected it as the active screen saver.
struct Installation: Equatable {
/// Canonical bundle location, also used to collapse symlinks.
let url: URL
/// Release version; nil means the bundle is recognized but its version is unavailable.
let version: String?
/// Distinguishes this process's metadata from metadata read off disk.
let isLoaded: Bool
}

/// Wallpaper copies relevant to this host.
let wallpapers: [Installation]
/// Loaded and installed screen-saver copies, deduplicated by canonical path.
let screenSavers: [Installation]
/// Records a failed discovery read, so an inaccessible directory is not treated as empty.
let discoveryIncomplete: Bool

/// Standard user and machine-wide screen-saver installation directories.
static var screenSaverDirectories: [URL] {
FileManager.default.urls(for: .libraryDirectory, in: [.userDomainMask, .localDomainMask])
.map { $0.appendingPathComponent("Screen Savers") }
}

/// Recovery destination shared by both native hosts.
static let releasesURL = URL(string: "https://github.com/patrickschaper/matrixScreenSaver/releases")!

/// Standard macOS warning triangle shared by dialogs, menus, and blocked playback.
static let warningImage = NSImage(named: NSImage.cautionName)

/// A confirmed differing pair takes precedence over other unreadable copies.
var state: State {
let wallpaperVersions = wallpapers.compactMap(\.version)
let saverVersions = screenSavers.compactMap(\.version)
if !wallpaperVersions.isEmpty && !saverVersions.isEmpty && Set(wallpaperVersions + saverVersions).count > 1 {
return .mismatch
}
if discoveryIncomplete || (wallpapers + screenSavers).contains(where: { $0.version == nil }) {
return .unverified
}
return wallpapers.isEmpty || screenSavers.isEmpty ? .missingCompanion : .matching
}

/// Shared heading for persistent warnings and the launch alert.
var title: String {
state == .mismatch ? "Version Mismatch" : "Version Unavailable"
}

/// Compact version information suitable for the menu and options banner.
var versionSummary: String {
"Wallpaper \(Self.versions(wallpapers)) · Screen Saver \(Self.versions(screenSavers))"
}

/// Gives the shortest recovery instruction for the current status.
var explanation: String {
if state == .mismatch {
return "Install matching versions to play."
}
return "Couldn’t check versions."
}

/// Keeps native dialogs to version numbers and one recovery sentence.
var message: String {
"\(versionSummary)\n\(explanation)"
}

/// Reads disk metadata afresh while preserving the hosting bundle's loaded version.
///
/// - Parameters:
/// - hostBundle: The bundle containing Matrix code, never System Settings or ScreenSaverEngine.
/// - screenSaverDirectories: Bounded installation directories; replace with isolated fixtures in tests.
/// - wallpaperURLs: Explicit candidates, or nil to use macOS discovery and standard app locations.
/// - Returns: A snapshot; inaccessible reads remain unverified rather than implying a match.
static func current(hostBundle: Bundle, screenSaverDirectories: [URL] = screenSaverDirectories,
wallpaperURLs: [URL]? = nil) -> MatrixVersionStatus {
let saverID = "dev.patsch.MatrixScreenSaver"
let wallpaperID = "dev.patsch.MatrixWallpaper"
var incomplete = false
var savers: [Installation] = []
var wallpapers: [Installation] = []

func read(_ url: URL, identifier: String) -> Installation? {
do {
let data = try Data(contentsOf: url.appendingPathComponent("Contents/Info.plist"))
guard let metadata = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
metadata["CFBundleIdentifier"] as? String == identifier else { return nil }
return Installation(url: url.resolvingSymlinksInPath().standardizedFileURL,
version: version(in: metadata), isLoaded: false)
} catch {
if !isMissing(error) { incomplete = true }
return nil
}
}

for directory in screenSaverDirectories {
do {
let urls = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
savers += urls.filter { $0.pathExtension.lowercased() == "saver" }.compactMap { read($0, identifier: saverID) }
} catch {
if !isMissing(error) { incomplete = true }
}
}

let hostID = hostBundle.bundleIdentifier
if hostID == wallpaperID || hostID == saverID {
let loaded = Installation(url: hostBundle.bundleURL.resolvingSymlinksInPath().standardizedFileURL,
version: version(in: hostBundle.infoDictionary ?? [:]), isLoaded: true)
if hostID == wallpaperID {
wallpapers = [loaded]
} else {
savers.removeAll { $0.url == loaded.url }
savers.append(loaded)
let candidates = wallpaperURLs ?? wallpaperCandidates(identifier: wallpaperID)
wallpapers = candidates.compactMap { read($0, identifier: wallpaperID) }
}
}
return MatrixVersionStatus(wallpapers: unique(wallpapers), screenSavers: unique(savers), discoveryIncomplete: incomplete)
}

private static func version(in metadata: [String: Any]) -> String? {
guard let value = (metadata["CFBundleShortVersionString"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}

private static func versions(_ installations: [Installation]) -> String {
guard !installations.isEmpty else { return "Not found" }
return Set(installations.map { $0.version ?? "Version unavailable" }).sorted().joined(separator: ", ")
}

private static func unique(_ installations: [Installation]) -> [Installation] {
var seen = Set<URL>()
return installations.filter { seen.insert($0.url).inserted }.sorted { $0.url.path < $1.url.path }
}

private static func isMissing(_ error: Error) -> Bool {
let error = error as NSError
return error.domain == NSCocoaErrorDomain && [NSFileNoSuchFileError, NSFileReadNoSuchFileError].contains(error.code)
}

private static func wallpaperCandidates(identifier: String) -> [URL] {
var urls = NSRunningApplication.runningApplications(withBundleIdentifier: identifier).compactMap(\.bundleURL)
urls += NSWorkspace.shared.urlsForApplications(withBundleIdentifier: identifier)
for directory in FileManager.default.urls(for: .applicationDirectory, in: [.userDomainMask, .localDomainMask]) {
for name in ["Matrix Wallpaper.app", "MatrixWallpaper.app"] {
urls.append(directory.appendingPathComponent(name))
}
}
return urls
}
}
Loading
Loading