diff --git a/AGENTS.md b/AGENTS.md index d9abccd..ecae650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: diff --git a/README.md b/README.md index 463900e..f0d6c0d 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 diff --git a/Resources/BuyMeACoffee.png b/Resources/BuyMeACoffee.png new file mode 100644 index 0000000..d7e05d6 Binary files /dev/null and b/Resources/BuyMeACoffee.png differ diff --git a/Resources/MenuBarIcon.pdf b/Resources/MenuBarIcon.pdf index 5c15d28..9ca509c 100644 Binary files a/Resources/MenuBarIcon.pdf and b/Resources/MenuBarIcon.pdf differ diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index e4b2946..e7ffc88 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -328,11 +328,6 @@ struct MatrixScreenSaverOptions: Equatable { } } -/// A top-origin document keeps the Rain options at the top when its content grows. -private final class RainOptionsDocumentView: NSView { - override var isFlipped: Bool { true } -} - final class MatrixScreenSaverOptionsSheetController: NSObject, NSTextFieldDelegate, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { @@ -368,7 +363,17 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let window: NSWindow private let rootStack = NSStackView() - private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Show Neo message scene", target: nil, action: nil) + /// The same detector is used in either host; refreshes never replace pending option edits. + private let readVersionStatus: () -> MatrixVersionStatus + private var versionStatus: MatrixVersionStatus? + private let versionBanner = NSStackView() + private let versionHeading = NSTextField(wrappingLabelWithString: "") + private let versionDescription = NSTextField(wrappingLabelWithString: "") + private let versionSymbol = NSImageView() + /// Only the tab content scrolls when the warning would push actions below the screen. + private let optionsScrollView = NSScrollView() + private var optionsHeight: NSLayoutConstraint? + private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Show Neo message", target: nil, action: nil) private let neoMessageSpeedFactorField = NSTextField(string: "") private let cmdOutputHoldSecondsField = NSTextField(string: "") private let nameField = NSTextField(string: "") @@ -398,7 +403,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private var maximumEditorRows: Int { editingCommands ? MatrixScreenSaverOptions.maxCommandCount : MatrixScreenSaverOptions.maxNeoMessageLineCount } - private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Show custom scene", target: nil, action: nil) + private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Run custom commands", target: nil, action: nil) /// Scene-owned editor buttons remain available only while their scene is enabled. private let commandsEditButton = NSButton(title: "Edit…", target: nil, action: nil) private let linesEditButton = NSButton(title: "Edit…", target: nil, action: nil) @@ -410,7 +415,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let commandsShuffleRadio = NSButton(radioButtonWithTitle: "All commands in random order", target: nil, action: nil) /// Commands accepted in the editor, not persisted until the main dialog is accepted. private var pendingCommands: [String] = [] - private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show trace program scene", target: nil, action: nil) + private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show trace program", target: nil, action: nil) private let twinkleCheckbox = NSButton(checkboxWithTitle: "Twinkling effect", target: nil, action: nil) private let diffuseCheckbox = NSButton(checkboxWithTitle: "Glow effect", target: nil, action: nil) private let scanLinesSlider: NSSlider = { @@ -461,8 +466,13 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Creates native options UI with a host-owned apply action. /// - /// - Parameter onApply: Receives validated options when the user chooses Apply. - init(onApply: @escaping (MatrixScreenSaverOptions) -> Void) { + /// - Parameters: + /// - readVersionStatus: Reads companion versions independently of pending preference edits. + /// - onApply: Receives validated options when the user chooses Save. + init(readVersionStatus: @escaping () -> MatrixVersionStatus = { + MatrixVersionStatus.current(hostBundle: Bundle(for: MatrixScreenSaverOptionsSheetController.self)) + }, onApply: @escaping (MatrixScreenSaverOptions) -> Void) { + self.readVersionStatus = readVersionStatus self.onApply = onApply window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1, height: 1), @@ -510,12 +520,35 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, updateRainGlyphControls() rebuildRainFontsMenu() updateSceneControls() - sizeWindowToFitContent() } /// Returns the native sheet window presented by ScreenSaver.framework. func configureSheet() -> NSWindow { - window + refreshVersionStatus() + return window + } + + /// Rechecks installations without reloading preferences or disturbing unsaved controls. + func refreshVersionStatus() { + let status = readVersionStatus() + guard status != versionStatus else { return } + versionStatus = status + versionBanner.isHidden = status.state != .mismatch && status.state != .unverified + versionHeading.stringValue = status.title + versionDescription.stringValue = status.message + versionSymbol.image = status.state == .mismatch ? MatrixVersionStatus.warningImage + : NSImage(systemSymbolName: "info.circle", accessibilityDescription: "Version unavailable") + versionSymbol.setAccessibilityLabel(status.state == .mismatch ? "Version mismatch" : "Version unavailable") + sizeWindowToFitContent() + } + + /// Reopening a retained sheet must reflect installations changed while it was inactive. + func windowDidBecomeKey(_ notification: Notification) { + refreshVersionStatus() + } + + @objc private func openReleases(_ sender: Any?) { + NSWorkspace.shared.open(MatrixVersionStatus.releasesURL) } /// Validates and applies the edited options. @@ -556,7 +589,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Constructs the native AppKit options sheet content and layout. private func buildWindow() { - window.title = "Matrix Screen Saver and animated Wallpaper" + window.title = "Matrix Options" window.isReleasedWhenClosed = false window.delegate = self @@ -588,48 +621,94 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rootStack.edgeInsets = NSEdgeInsets(top: 20, left: 20, bottom: 20, right: 20) rootStack.translatesAutoresizingMaskIntoConstraints = false - let aboutButton = NSButton(title: "About", target: self, action: #selector(openRepository(_:))) + versionHeading.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + versionDescription.font = .systemFont(ofSize: NSFont.systemFontSize) + versionDescription.isSelectable = true + let warningText = NSStackView(views: [versionHeading, versionDescription]) + warningText.orientation = .vertical + warningText.alignment = .leading + warningText.spacing = 4 + let releaseButton = NSButton(title: "View Releases…", target: self, action: #selector(openReleases(_:))) + warningText.addArrangedSubview(releaseButton) + versionBanner.orientation = .horizontal + versionBanner.alignment = .top + versionBanner.spacing = 12 + versionBanner.addArrangedSubview(versionSymbol) + versionBanner.addArrangedSubview(warningText) + versionBanner.isHidden = true + rootStack.addArrangedSubview(versionBanner) + NSLayoutConstraint.activate([ + versionBanner.widthAnchor.constraint(equalToConstant: 640), + versionSymbol.widthAnchor.constraint(equalToConstant: 24), + versionSymbol.heightAnchor.constraint(equalToConstant: 24), + warningText.widthAnchor.constraint(equalTo: versionBanner.widthAnchor, constant: -36), + versionHeading.widthAnchor.constraint(equalTo: warningText.widthAnchor), + versionDescription.widthAnchor.constraint(equalTo: warningText.widthAnchor), + ]) + + let aboutButton = NSButton(title: "About the project", target: self, action: #selector(openRepository(_:))) aboutButton.bezelStyle = .inline aboutButton.isBordered = false aboutButton.contentTintColor = .linkColor aboutButton.font = .systemFont(ofSize: 13) // Tab pages lay out during insertion, before Auto Layout sizes the tab view. - let tabs = NSTabView(frame: NSRect(x: 0, y: 0, width: 520, height: 520)) + let tabs = NSTabView(frame: NSRect(x: 0, y: 0, width: 640, height: 620)) tabs.translatesAutoresizingMaskIntoConstraints = false tabs.addTabViewItem(makeOptionsTab(title: "General", views: [ - makeCharacterSizeSection(), - makeScanLinesSection(), - makeNumericSection(title: "Frame rate", field: frameRateField, description: MatrixScreenSaverOptions.frameRateDescription), + makeDescriptionLabel("Shared settings for the screen saver and animated wallpaper."), + makeOptionsGroup(title: "Rendering", views: [ + makeCharacterSizeSection(), + makeNumericSection(title: "Frame rate", field: frameRateField, description: "Frames per second. Higher values use more energy."), + ]), + makeOptionsGroup(title: "Shared effects", views: [ + makeScanLinesSection(), + makeCheckboxSection(checkbox: twinkleCheckbox, description: "Vary character brightness in the rain and trace program."), + ]), ])) tabs.addTabViewItem(makeOptionsTab(title: "Intro scenes", views: [ - makeSceneGroup(views: [ - makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription), - makeTextSection(title: "Name", field: nameField, description: MatrixScreenSaverOptions.nameDescription, fieldWidth: Self.textFieldWidth / 2), + makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: "Speed multiplier for all intro scenes. 1 is normal speed."), + makeOptionsGroup(title: "1. Neo message", views: [ + neoMessageSceneCheckbox, + makeTextSection(title: "Name", field: nameField, description: "Used in “Wake up, {name}…”. Leave empty for Neo."), makeNeoMessageLinesButtonRow(), ]), - makeSceneGroup(views: [makeCustomShellCommandsSection()]), - makeSceneGroup(views: [makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription)]), - makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: MatrixScreenSaverOptions.neoMessageSpeedFactorDescription), + makeOptionsGroup(title: "2. Custom commands", views: [makeCustomShellCommandsSection()]), + makeOptionsGroup(title: "3. Trace program", views: [numberSceneCheckbox]), ])) tabs.addTabViewItem(makeOptionsTab(title: "Rain", views: [ - makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), - makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), - makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), - makeRainDurationSection(), - makeRainGlyphSection(), - makeRainFontsSection(), - makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), - makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), + makeDescriptionLabel("The falling characters shown after the intro scenes."), + makeOptionsGroup(title: "Characters", views: [ + makeRainGlyphSection(), + makeRainFontsSection(), + makeTextSection(title: "Custom glyphs", field: charactersField, description: "Leave empty to use the selected font’s character set."), + ]), + makeOptionsGroup(title: "Animation", views: [ + makeNumericSection(title: "Density", field: rainDensityField, description: "Number of falling streams. 1 is normal density."), + makeRainDurationSection(), + makeNumericSection(title: "Change rate", field: errorRateField, description: "How often characters change. 0 keeps each character fixed."), + ]), + makeOptionsGroup(title: "Effects", views: [ + makeCheckboxSection(checkbox: diffuseCheckbox, description: "Add a soft glow around falling characters."), + ]), ])) - rootStack.addArrangedSubview(tabs) + optionsScrollView.borderType = .noBorder + optionsScrollView.drawsBackground = false + optionsScrollView.hasVerticalScroller = true + optionsScrollView.autohidesScrollers = true + optionsScrollView.scrollerStyle = .overlay + optionsScrollView.documentView = tabs + rootStack.addArrangedSubview(optionsScrollView) + optionsHeight = optionsScrollView.heightAnchor.constraint(equalToConstant: 620) + optionsHeight?.isActive = true let groupWidth = -(rootStack.edgeInsets.left + rootStack.edgeInsets.right) - // Keep every page the same size so switching tabs never moves the dialog buttons. + // Reserve space for the longest page so every setting stays visible when switching tabs. NSLayoutConstraint.activate([ - tabs.widthAnchor.constraint(equalToConstant: 520), - tabs.heightAnchor.constraint(equalToConstant: 550), - tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), + tabs.widthAnchor.constraint(equalToConstant: 640), + tabs.heightAnchor.constraint(equalToConstant: 620), + optionsScrollView.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), + optionsScrollView.widthAnchor.constraint(equalToConstant: 640), ]) let buttons = NSStackView() @@ -638,11 +717,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, buttons.alignment = .centerY buttons.translatesAutoresizingMaskIntoConstraints = false - let resetButton = NSButton(title: "Reset to defaults", target: self, action: #selector(resetToDefaults(_:))) + let resetButton = NSButton(title: "Restore Defaults", target: self, action: #selector(resetToDefaults(_:))) let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancel(_:))) - let okButton = NSButton(title: "OK", target: self, action: #selector(apply(_:))) + let okButton = NSButton(title: "Save", target: self, action: #selector(apply(_:))) okButton.keyEquivalent = "\r" cancelButton.keyEquivalent = "\u{1b}" + okButton.bezelStyle = .rounded + cancelButton.bezelStyle = .rounded + resetButton.bezelStyle = .rounded + okButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72).isActive = true + cancelButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 72).isActive = true buttons.addArrangedSubview(cancelButton) buttons.addArrangedSubview(okButton) @@ -657,16 +741,23 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, .infoDictionary?["CFBundleShortVersionString"] as? String ?? "" let versionLabel = NSTextField(labelWithString: versionString.isEmpty ? "" : "v\(versionString)") versionLabel.font = .systemFont(ofSize: 11) - versionLabel.textColor = .tertiaryLabelColor + versionLabel.textColor = .secondaryLabelColor versionLabel.setContentHuggingPriority(.required, for: .horizontal) - let coffeeButton = NSButton(title: "Buy me a coffee ☕", target: self, action: #selector(openDonationPage(_:))) + let coffeeButton = NSButton(title: "Buy Me a Coffee", target: self, action: #selector(openDonationPage(_:))) + // Source: https://cdn.buymeacoffee.com/assets/homepage/meta/favicon-32x32.png + coffeeButton.image = Bundle(for: MatrixScreenSaverOptionsSheetController.self).image(forResource: "BuyMeACoffee") + coffeeButton.image?.size = NSSize(width: 16, height: 16) + coffeeButton.image?.isTemplate = false + coffeeButton.imagePosition = .imageLeft coffeeButton.bezelStyle = .inline coffeeButton.isBordered = false coffeeButton.controlSize = .small coffeeButton.contentTintColor = .linkColor coffeeButton.font = aboutButton.font coffeeButton.setContentHuggingPriority(.required, for: .horizontal) + // The native centered title uses half the extra width as icon-to-label spacing. + coffeeButton.widthAnchor.constraint(greaterThanOrEqualToConstant: coffeeButton.intrinsicContentSize.width + 8).isActive = true let spacerRight = NSView() spacerRight.translatesAutoresizingMaskIntoConstraints = false @@ -674,12 +765,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, spacerRight.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) footer.addArrangedSubview(resetButton) - footer.addArrangedSubview(aboutButton) - footer.addArrangedSubview(versionLabel) - footer.addArrangedSubview(coffeeButton) footer.addArrangedSubview(spacerRight) footer.addArrangedSubview(buttons) + let informationSpacer = NSView() + informationSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + let information = NSStackView(views: [aboutButton, versionLabel, informationSpacer, coffeeButton]) + information.alignment = .centerY + information.spacing = 8 + rootStack.addArrangedSubview(information) + information.widthAnchor.constraint(equalTo: tabs.widthAnchor).isActive = true + let separator = NSBox() + separator.boxType = .separator + rootStack.addArrangedSubview(separator) + separator.widthAnchor.constraint(equalTo: tabs.widthAnchor).isActive = true + rootStack.setCustomSpacing(10, after: information) + rootStack.setCustomSpacing(12, after: separator) rootStack.addArrangedSubview(footer) buttons.setHuggingPriority(.required, for: .horizontal) resetButton.setContentHuggingPriority(.required, for: .horizontal) @@ -700,19 +801,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, sizeWindowToFitContent() } - /// Wraps scene controls in a native, untitled fieldset without changing their actions. - private func makeSceneGroup(views: [NSView]) -> NSBox { + /// Groups controls by scope, using native borders and a readable section heading. + private func makeOptionsGroup(title: String, views: [NSView]) -> NSBox { let box = NSBox() - box.titlePosition = .noTitle - box.contentViewMargins = NSSize(width: 8, height: 8) + box.title = title + box.titleFont = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + box.contentViewMargins = NSSize(width: 12, height: 10) let stack = NSStackView(views: views) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 6 + stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false let content = box.contentView! content.addSubview(stack) NSLayoutConstraint.activate([ + // A single-checkbox group still needs room for the native box's content margins. + box.heightAnchor.constraint(greaterThanOrEqualTo: stack.heightAnchor, constant: box.contentViewMargins.height * 2), stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), stack.topAnchor.constraint(equalTo: content.topAnchor), @@ -745,7 +849,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, commandsEditButton.action = #selector(editCommands(_:)) commandsEditButton.setAccessibilityLabel("Edit custom shell commands") let label = NSTextField(labelWithString: "Commands") - label.font = .systemFont(ofSize: 13, weight: .semibold) + label.font = .systemFont(ofSize: NSFont.systemFontSize) label.widthAnchor.constraint(equalToConstant: 100).isActive = true let heading = NSStackView(views: [label, commandsEditButton]) heading.spacing = 12 @@ -758,15 +862,15 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, modes.alignment = .leading modes.spacing = 4 let stack = NSStackView(views: [ - makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), + cmdSceneCheckbox, heading, modes, makeNumericSection(title: "Output hold", field: cmdOutputHoldSecondsField, - description: MatrixScreenSaverOptions.cmdOutputHoldSecondsDescription), + description: "Seconds to show output after each command finishes. 0 continues immediately."), ]) cmdOutputHoldSecondsField.setAccessibilityLabel("Command output hold in seconds") stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 4 + stack.spacing = 6 return stack } @@ -797,19 +901,20 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.alignment = .centerY let label = NSTextField(labelWithString: "Lines") - label.font = .systemFont(ofSize: 13, weight: .semibold) + label.font = .systemFont(ofSize: NSFont.systemFontSize) label.setContentHuggingPriority(.required, for: .horizontal) label.widthAnchor.constraint(equalToConstant: 100).isActive = true linesEditButton.target = self linesEditButton.action = #selector(editLines(_:)) linesEditButton.setContentHuggingPriority(.required, for: .horizontal) + linesEditButton.setAccessibilityLabel("Edit Neo message lines") row.addArrangedSubview(label) row.addArrangedSubview(linesEditButton) stack.addArrangedSubview(row) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.neoMessageLinesDescription)) + stack.addArrangedSubview(makeDescriptionLabel("Customize the intro message, or use the default greeting.")) return stack } @@ -1179,7 +1284,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.alignment = .centerY let label = NSTextField(labelWithString: "Scan lines") - label.font = .systemFont(ofSize: 13, weight: .semibold) + label.font = .systemFont(ofSize: NSFont.systemFontSize) label.setContentHuggingPriority(.required, for: .horizontal) label.widthAnchor.constraint(equalToConstant: 100).isActive = true scanLinesSlider.setAccessibilityLabel("Scan line intensity") @@ -1200,7 +1305,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.addArrangedSubview(scanLinesDirectionPopup) stack.addArrangedSubview(row) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.scanLinesDescription)) + stack.addArrangedSubview(makeDescriptionLabel("CRT texture across all scenes. Set to 0% to turn it off.")) return stack } @@ -1236,7 +1341,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.alignment = .centerY let label = NSTextField(labelWithString: title) - label.font = .systemFont(ofSize: 13, weight: .semibold) + label.font = .systemFont(ofSize: NSFont.systemFontSize) label.setContentHuggingPriority(.required, for: .horizontal) label.widthAnchor.constraint(equalToConstant: 100).isActive = true field.setAccessibilityLabel(title) @@ -1254,9 +1359,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Keeps the rain-only preset, sample, and customization guidance together. private func makeRainGlyphSection() -> NSView { let heading = NSTextField(labelWithString: "Glyph set") - heading.font = .systemFont(ofSize: 13, weight: .semibold) + heading.font = .systemFont(ofSize: NSFont.systemFontSize) for (index, radio) in rainGlyphRadios.enumerated() { radio.tag = index radio.target = self @@ -1267,15 +1373,19 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainGlyphPreview.drawsBackground = true rainGlyphPreview.backgroundColor = .black rainGlyphPreview.textColor = .green + // Fonts can contain thousands of glyphs; the sample must not displace the settings below it. rainGlyphPreview.lineBreakMode = .byCharWrapping - rainGlyphPreview.maximumNumberOfLines = 0 - rainGlyphPreview.setAccessibilityLabel("Selected rain glyphs") - let stack = NSStackView(views: [heading, row, rainGlyphPreview]) + rainGlyphPreview.maximumNumberOfLines = 3 + rainGlyphPreview.heightAnchor.constraint(equalToConstant: 48).isActive = true + rainGlyphPreview.setAccessibilityLabel("Rain glyph sample") + let note = makeDescriptionLabel("Classic and Resurrections include their own font and glyphs. Choose Default to customize them below.") + let stack = NSStackView(views: [heading, row, rainGlyphPreview, note]) stack.orientation = .vertical stack.alignment = .leading - stack.spacing = 4 + stack.spacing = 8 stack.setHuggingPriority(.required, for: .vertical) rainGlyphPreview.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + note.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true row.toolTip = "Classic and Resurrections use their original bundled fonts. Default uses the settings below." return stack } @@ -1321,11 +1431,12 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.setHuggingPriority(.required, for: .vertical) let heading = NSTextField(labelWithString: "Font") - heading.font = .systemFont(ofSize: 13, weight: .semibold) + heading.font = .systemFont(ofSize: NSFont.systemFontSize) + heading.widthAnchor.constraint(equalToConstant: 100).isActive = true rainFontsButton.setAccessibilityLabel("Font") rainFontsButton.translatesAutoresizingMaskIntoConstraints = false - rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true + rainFontsButton.widthAnchor.constraint(equalToConstant: 220).isActive = true (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail let resetButton = rainFontsResetButton @@ -1336,15 +1447,12 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) resetButton.setAccessibilityLabel("Reset font to system default") resetButton.toolTip = "Use the default system font" - let row = NSStackView(views: [rainFontsButton, resetButton]) + let row = NSStackView(views: [heading, rainFontsButton, resetButton]) row.orientation = .horizontal row.alignment = .centerY - row.spacing = 8 + row.spacing = 12 - stack.addArrangedSubview(heading) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription)) stack.addArrangedSubview(row) - stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) return stack } @@ -1395,7 +1503,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, } /// Builds a labeled text input row with its description. - private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { + private func makeTextSection(title: String, field: NSTextField, description: String) -> NSView { let stack = NSStackView() stack.orientation = .vertical stack.alignment = .leading @@ -1408,7 +1516,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.alignment = .centerY let label = NSTextField(labelWithString: title) - label.font = .systemFont(ofSize: 13, weight: .semibold) + label.font = .systemFont(ofSize: NSFont.systemFontSize) label.setContentHuggingPriority(.required, for: .horizontal) label.widthAnchor.constraint(equalToConstant: 100).isActive = true field.setAccessibilityLabel(title) @@ -1416,7 +1524,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, field.alignment = .left field.controlSize = .regular field.translatesAutoresizingMaskIntoConstraints = false - field.widthAnchor.constraint(equalToConstant: fieldWidth ?? Self.textFieldWidth).isActive = true + field.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true row.addArrangedSubview(label) row.addArrangedSubview(field) @@ -1447,7 +1555,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, secondsRow.alignment = .centerY let secondsLabel = NSTextField(labelWithString: "Seconds") - secondsLabel.font = .systemFont(ofSize: 13, weight: .semibold) + secondsLabel.font = .systemFont(ofSize: NSFont.systemFontSize) secondsLabel.setContentHuggingPriority(.required, for: .horizontal) secondsLabel.widthAnchor.constraint(equalToConstant: 100).isActive = true rainDurationField.setAccessibilityLabel("Rain duration in seconds") @@ -1462,7 +1570,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.addArrangedSubview(checkboxRow) stack.addArrangedSubview(secondsRow) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) + stack.addArrangedSubview(makeDescriptionLabel("When off, rain winds down after this many seconds, then the scene sequence repeats.")) return stack } @@ -1480,7 +1588,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.alignment = .centerY let titleLabel = NSTextField(labelWithString: "Character size") - titleLabel.font = .systemFont(ofSize: 13, weight: .semibold) + titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize) titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.widthAnchor.constraint(equalToConstant: 100).isActive = true characterSizeSlider.setAccessibilityLabel("Character size") @@ -1497,7 +1605,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, row.addArrangedSubview(characterSizeValueLabel) stack.addArrangedSubview(row) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.characterSizeDescription)) + stack.addArrangedSubview(makeDescriptionLabel("Character cell dimensions in pixels, used by every scene.")) return stack } @@ -1518,21 +1626,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, item.label = title // Start at the real page size so NSTabView never lays the page out at its 20pt placeholder width, // which made the fixed-width rows report unsatisfiable constraints in the screen saver host. - let frame = NSRect(x: 0, y: 0, width: 520, height: 550) - let scrolls = title == "Rain" - let contentView = scrolls ? RainOptionsDocumentView(frame: frame) : NSView(frame: frame) - if scrolls { - let scrollView = NSScrollView(frame: frame) - scrollView.hasVerticalScroller = true - scrollView.autohidesScrollers = true - scrollView.drawsBackground = false - scrollView.documentView = contentView - contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor).isActive = true - item.view = scrollView - } else { - item.view = contentView - } + let frame = NSRect(x: 0, y: 0, width: 640, height: 620) + let contentView = NSView(frame: frame) + item.view = contentView let stack = NSStackView(views: views) stack.orientation = .vertical stack.alignment = .leading @@ -1545,8 +1641,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16), stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16), - scrolls ? stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) - : stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), + stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), ]) for view in views { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true @@ -1558,7 +1653,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Creates the smaller descriptive text used under each option row. private func makeDescriptionLabel(_ description: String) -> NSTextField { let label = NSTextField(wrappingLabelWithString: description) - label.font = .systemFont(ofSize: 11) + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize) label.textColor = .secondaryLabelColor label.alignment = .left label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) @@ -1712,20 +1807,30 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, value.trimmingCharacters(in: .whitespacesAndNewlines) } - /// Resizes the sheet window so it fits the current stack view content. + /// Fits the fixed-size pages and action area without making the window user-resizable. private func sizeWindowToFitContent() { guard let contentView = window.contentView else { return } + contentView.layoutSubtreeIfNeeded() + // Keep the warning and Save/Cancel on screen; only the fixed-size tab document scrolls. + let currentHeight = optionsHeight?.constant ?? 620 + let chromeHeight = window.frame.height - window.contentRect(forFrameRect: window.frame).height + let availableHeight = (window.screen ?? NSScreen.main)?.visibleFrame.height ?? 1000 + let otherHeight = rootStack.fittingSize.height - currentHeight + optionsHeight?.constant = min(620, max(120, availableHeight - chromeHeight - otherHeight - 24)) contentView.layoutSubtreeIfNeeded() let fittingSize = rootStack.fittingSize let contentSize = NSSize( width: ceil(fittingSize.width), height: ceil(fittingSize.height) ) - window.setContentSize(contentSize) window.contentMinSize = contentSize + window.setContentSize(contentSize) + if optionsHeight?.constant != currentHeight, let document = optionsScrollView.documentView { + document.scroll(NSPoint(x: 0, y: max(0, document.frame.height - optionsScrollView.contentSize.height))) + } } private static let fourDigitFieldWidth: CGFloat = { diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c938184..b88cf2e 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -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 @@ -245,6 +253,17 @@ 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() } @@ -252,7 +271,6 @@ final class MatrixScreenSaverView: ScreenSaverView { 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 @@ -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 @@ -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 @@ -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)) || @@ -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() @@ -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 diff --git a/Sources/MatrixScreenSaver/MatrixVersionStatus.swift b/Sources/MatrixScreenSaver/MatrixVersionStatus.swift new file mode 100644 index 0000000..00c13d7 --- /dev/null +++ b/Sources/MatrixScreenSaver/MatrixVersionStatus.swift @@ -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() + 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 + } +} diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index 079d4d6..7a15d08 100644 --- a/Sources/MatrixWallpaper/WallpaperApp.swift +++ b/Sources/MatrixWallpaper/WallpaperApp.swift @@ -16,8 +16,17 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Wallpaper-only preferences, separate from shared rendering options. private let defaults: UserDefaults - /// Installed saver locations, replaceable with temporary directories in tests. - private let screenSaverDirectories: [URL] + /// Shared discovery also drives synchronization availability without a second directory scan. + private let readVersionStatus: () -> MatrixVersionStatus + /// Keeps the modal side effect replaceable in native host tests. + private let presentVersionAlert: (NSAlert) -> NSApplication.ModalResponse + private var versionStatus: MatrixVersionStatus? + private var versionRefreshTimer: Timer? + private var versionAlert: NSAlert? + private var normalStatusImage: NSImage? + private var warningStatusImage: NSImage? + private let versionWarningItem = NSMenuItem(title: "Version Mismatch…", action: #selector(showVersionWarning(_:)), keyEquivalent: "") + private let versionSeparator = NSMenuItem.separator() /// Persists user intent even while no saver is installed. private static let syncPreferenceKey = "syncWithScreenSaver" /// Reflects whether the wallpaper may serve frames to the saver. @@ -30,13 +39,19 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// - defaults: Store for wallpaper-only preferences. /// - screenSaverDirectories: User and local installed-saver directories. /// - desktopWallpaper: Durable desktop settings controller, isolated in tests. + /// - readVersionStatus: Optional isolated discovery, shared with the options sheet. + /// - presentVersionAlert: Presents the native alert and returns the chosen action. init(loginItems: LoginItemController = LoginItemController(), defaults: UserDefaults = .standard, - screenSaverDirectories: [URL] = FileManager.default.urls(for: .libraryDirectory, in: [.userDomainMask, .localDomainMask]) - .map { $0.appendingPathComponent("Screen Savers") }, - desktopWallpaper: DesktopWallpaperController = DesktopWallpaperController()) { + screenSaverDirectories: [URL] = MatrixVersionStatus.screenSaverDirectories, + desktopWallpaper: DesktopWallpaperController = DesktopWallpaperController(), + readVersionStatus: (() -> MatrixVersionStatus)? = nil, + presentVersionAlert: @escaping (NSAlert) -> NSApplication.ModalResponse = { $0.runModal() }) { self.loginItems = loginItems self.defaults = defaults - self.screenSaverDirectories = screenSaverDirectories + self.readVersionStatus = readVersionStatus ?? { + MatrixVersionStatus.current(hostBundle: Bundle(for: WallpaperApp.self), screenSaverDirectories: screenSaverDirectories) + } + self.presentVersionAlert = presentVersionAlert self.desktopWallpaper = desktopWallpaper super.init() defaults.register(defaults: [Self.syncPreferenceKey: true]) @@ -55,10 +70,10 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { private var isTerminating = false /// Menu action whose label follows requested playback, not temporary suspension. - private let playbackButton = NSButton(title: "Stop", target: nil, action: #selector(togglePlayback(_:))) + private let playbackItem = NSMenuItem(title: "Stop Wallpaper", action: #selector(togglePlayback(_:)), keyEquivalent: "") /// Manual pause remains available independently of temporary system suspension. - private let pauseButton = NSButton(title: "Pause", target: nil, action: #selector(togglePause(_:))) + private let pauseItem = NSMenuItem(title: "Pause Animation", action: #selector(togglePause(_:)), keyEquivalent: "") /// Retains native options UI independently of transient wallpaper views. private var optionsController: MatrixScreenSaverOptionsSheetController? @@ -102,37 +117,59 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { } else { item.button?.title = "M" } - item.button?.toolTip = "Matrix Wallpaper" item.button?.setAccessibilityLabel("Matrix Wallpaper") + normalStatusImage = item.button?.image + let warningSymbol = NSImage(systemSymbolName: "exclamationmark.triangle.fill", accessibilityDescription: "Version mismatch") + if let normalStatusImage, let warningSymbol { + warningStatusImage = NSImage(size: NSSize(width: 32, height: 18), flipped: false) { rect in + normalStatusImage.draw(in: NSRect(x: 0, y: 0, width: 18, height: 18)) + warningSymbol.draw(in: NSRect(x: 19, y: 2, width: 13, height: 14)) + return true + } + warningStatusImage?.isTemplate = true + } else { + warningStatusImage = warningSymbol + item.button?.imagePosition = .imageRight + } let menu = NSMenu() menu.autoenablesItems = false menu.delegate = self - for button in [playbackButton, pauseButton] { - button.target = self - button.bezelStyle = .rounded + versionWarningItem.target = self + versionWarningItem.image = MatrixVersionStatus.warningImage?.copy() as? NSImage + versionWarningItem.image?.size = NSSize(width: 18, height: 18) + versionWarningItem.image?.isTemplate = false + // macOS 27 hides menu images by default; KVC keeps builds compatible with older SDKs. + if versionWarningItem.responds(to: NSSelectorFromString("setPreferredImageVisibility:")) { + versionWarningItem.setValue(1, forKey: "preferredImageVisibility") // NSMenuItem.ImageVisibility.visible + } + for warningItem in [versionWarningItem, versionSeparator] { + warningItem.isHidden = true + menu.addItem(warningItem) } - let controls = NSStackView(views: [playbackButton, pauseButton]) - controls.orientation = .horizontal - controls.alignment = .centerY - controls.distribution = .fillEqually - controls.spacing = 8 - controls.edgeInsets = NSEdgeInsets(top: 4, left: 14, bottom: 4, right: 14) - controls.frame = NSRect(x: 0, y: 0, width: 240, height: 36) - controls.autoresizingMask = [.width] - let playbackRow = NSMenuItem(title: "Playback", action: nil, keyEquivalent: "") - playbackRow.view = controls - menu.addItem(playbackRow) - let options = NSMenuItem(title: "Options…", action: #selector(showOptions(_:)), keyEquivalent: "") + pauseItem.target = self + playbackItem.target = self + updatePlaybackMenu() + menu.addItem(pauseItem) + menu.addItem(playbackItem) + menu.addItem(.separator()) + let options = NSMenuItem(title: "Settings…", action: #selector(showOptions(_:)), keyEquivalent: ",") options.target = self menu.addItem(options) syncItem.target = self - menu.addItem(.separator()) menu.addItem(loginItems.item) menu.addItem(syncItem) menu.addItem(.separator()) - let coffee = NSMenuItem(title: "Buy me a coffee ☕", action: #selector(openDonationPage(_:)), keyEquivalent: "") + let coffee = NSMenuItem(title: "Buy Me a Coffee", action: #selector(openDonationPage(_:)), keyEquivalent: "") + coffee.image = Bundle.main.image(forResource: "BuyMeACoffee") + coffee.image?.size = NSSize(width: 16, height: 16) + coffee.image?.isTemplate = false + // macOS 27 hides menu images by default; retain the full-color favicon. + if coffee.responds(to: NSSelectorFromString("setPreferredImageVisibility:")) { + coffee.setValue(1, forKey: "preferredImageVisibility") + } coffee.target = self menu.addItem(coffee) + menu.addItem(.separator()) let quit = NSMenuItem(title: "Quit Matrix Wallpaper", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") quit.target = NSApp menu.addItem(quit) @@ -141,6 +178,10 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { menuWillOpen(menu) observeSystemState() + versionRefreshTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + self?.refreshVersionStatus() + } + versionRefreshTimer?.tolerance = 10 frameLeaseTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in guard let self else { return } let now = ProcessInfo.processInfo.systemUptime @@ -166,6 +207,9 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { }) let startup = DispatchWorkItem { [weak self] in guard let self, !self.isTerminating, self.pendingLaunch != nil else { return } + self.showVersionWarning(nil) + // A modal warning can receive Stop or Quit before desktop recovery starts. + guard !self.isTerminating, self.pendingLaunch != nil else { return } if self.playback.isRequested { self.updateDesktopWallpaper(restoring: true) } // Recovery alerts can pump the run loop and receive Stop or Quit. guard !self.isTerminating, self.pendingLaunch != nil else { return } @@ -196,16 +240,50 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { func menuWillOpen(_ menu: NSMenu) { loginItems.menuWillOpen(menu) syncItem.state = defaults.bool(forKey: Self.syncPreferenceKey) ? .on : .off - syncItem.isHidden = !screenSaverDirectories.contains { directory in - let bundles = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? [] - return bundles.contains { url in - guard url.pathExtension.lowercased() == "saver", - let data = try? Data(contentsOf: url.appendingPathComponent("Contents/Info.plist")), - let metadata = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil) as? [String: Any] - else { return false } - return metadata["CFBundleIdentifier"] as? String == "dev.patsch.MatrixScreenSaver" + refreshVersionStatus() + } + + /// Refreshes persistent warnings independently of animation and pending option edits. + private func refreshVersionStatus() { + guard !isTerminating else { return } + let status = readVersionStatus() + syncItem.isHidden = status.screenSavers.isEmpty + if status != versionStatus { + let hadStatus = versionStatus != nil + let wasMismatch = versionStatus?.state == .mismatch + versionStatus = status + let mismatch = status.state == .mismatch + for item in [versionWarningItem, versionSeparator] { + item.isHidden = !mismatch } + statusItem?.button?.image = mismatch ? warningStatusImage : normalStatusImage + statusItem?.button?.setAccessibilityLabel(mismatch ? "Matrix Wallpaper — version mismatch" : "Matrix Wallpaper") + if mismatch { endFrameSharing() } + if hadStatus { reconcilePlayback(restart: wasMismatch != mismatch) } } + optionsController?.refreshVersionStatus() + } + + /// Automatic presentation occurs only in queued launch; the menu can reopen it explicitly. + @objc private func showVersionWarning(_ sender: Any?) { + refreshVersionStatus() + guard !isTerminating, versionAlert == nil, let status = versionStatus, status.state == .mismatch else { return } + let alert = NSAlert() + alert.alertStyle = .warning + alert.icon = MatrixVersionStatus.warningImage + alert.messageText = status.title + alert.informativeText = status.message + alert.addButton(withTitle: "View Releases…") + alert.addButton(withTitle: "Close") + versionAlert = alert + NSApp.activate(ignoringOtherApps: true) + let response = presentVersionAlert(alert) + versionAlert = nil + if !isTerminating && response == .alertFirstButtonReturn { openReleases(nil) } + } + + @objc private func openReleases(_ sender: Any?) { + NSWorkspace.shared.open(MatrixVersionStatus.releasesURL) } /// Stores the choice without changing the wallpaper's requested playback state. @@ -246,7 +324,7 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { NSApp.activate(ignoringOtherApps: true) return } - let controller = MatrixScreenSaverOptionsSheetController { [weak self] options in + let controller = MatrixScreenSaverOptionsSheetController(readVersionStatus: readVersionStatus) { [weak self] options in self?.applyOptions(options) } controller.onClose = { [weak self] in self?.optionsController = nil } @@ -282,9 +360,7 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// - Parameter restart: Forces coordinated reconstruction after options or display changes. private func reconcilePlayback(restart: Bool = false) { guard !isTerminating else { return } - playbackButton.title = playback.isRequested ? "Stop" : "Start" - pauseButton.title = playback.isPaused ? "Resume" : "Pause" - pauseButton.isEnabled = playback.isRequested + updatePlaybackMenu() guard pendingLaunch == nil else { return } if !playback.isRequested { updateDesktopWallpaper(restoring: true) } if !playback.isRequested || restart { removeDesktopWindows() } @@ -306,6 +382,7 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { return } saver.isWallpaperHost = true + saver.readVersionStatus = readVersionStatus saver.rendersWallpaperWhileHidden = playback.isServingScreenSaver let window = DesktopWindow(screen: screen, saverView: saver) windows.append(window) @@ -339,6 +416,15 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { } } + /// Keeps native command labels, symbols, and availability tied to user intent rather than system suspension. + private func updatePlaybackMenu() { + playbackItem.title = playback.isRequested ? "Stop Wallpaper" : "Start Wallpaper" + playbackItem.image = NSImage(systemSymbolName: playback.isRequested ? "stop.fill" : "play.fill", accessibilityDescription: nil) + pauseItem.title = playback.isPaused ? "Resume Animation" : "Pause Animation" + pauseItem.image = NSImage(systemSymbolName: playback.isPaused ? "play.fill" : "pause.fill", accessibilityDescription: nil) + pauseItem.isEnabled = playback.isRequested + } + /// Changes desktop ownership without coupling it to transient animation windows. /// - Parameter restoring: Restores originals instead of acquiring newly connected desktops. private func updateDesktopWallpaper(restoring: Bool) { @@ -427,7 +513,7 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// /// - Parameter displayID: Display requested through the sandbox-safe discovery notification. private func shareFrames(for displayID: UInt32) { - guard defaults.bool(forKey: Self.syncPreferenceKey), playback.isRequested, + guard versionStatus?.state != .mismatch, defaults.bool(forKey: Self.syncPreferenceKey), playback.isRequested, currentDisplayLayout.contains(where: { $0.id == displayID }) else { return } let starting = !playback.isServingScreenSaver if starting { @@ -463,6 +549,12 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { playback.isRequested = false frameLeaseTimer?.invalidate() frameLeaseTimer = nil + versionRefreshTimer?.invalidate() + versionRefreshTimer = nil + if let versionAlert { + NSApp.abortModal() + versionAlert.window.orderOut(nil) + } for (center, token) in observers { center.removeObserver(token) } observers.removeAll() // Installers request Quit too; a recovery error must not hold termination in a modal loop. diff --git a/Tests/Homebrew/documentation_test.py b/Tests/Homebrew/documentation_test.py index 738686a..f805519 100644 --- a/Tests/Homebrew/documentation_test.py +++ b/Tests/Homebrew/documentation_test.py @@ -20,6 +20,15 @@ def test_user_lifecycle_and_security(self): self.assertIn("https://www.buymeacoffee.com/yesman82", text) self.assertIn("docs/bmc_qr.png", text) + def test_wallpaper_internals_live_in_developer_notes(self): + readme = (ROOT / "README.md").read_text() + developer = (ROOT / "docs/development.md").read_text() + self.assertIn("(docs/development.md)", readme) + for detail in ["dev.patsch.MatrixWallpaper", "Disconnected displays and failed restores", + "Checks compare the loaded host", "restoring the previous installation if replacement fails"]: + self.assertNotIn(detail, readme) + self.assertIn(detail, developer) + def test_maintainer_permissions_and_compatibility(self): text = (ROOT / "Homebrew/README.md").read_text() for phrase in ["HOMEBREW_TAP_REPOSITORY", "HOMEBREW_TAP_TOKEN", "contents", diff --git a/Tests/MatrixScreenSaverTests/MatrixVersionStatusTests.swift b/Tests/MatrixScreenSaverTests/MatrixVersionStatusTests.swift new file mode 100644 index 0000000..ced03ab --- /dev/null +++ b/Tests/MatrixScreenSaverTests/MatrixVersionStatusTests.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Exercises installed metadata without changing real bundles or shared preferences. +func matrixVersionStatusTests() { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + let user = root.appendingPathComponent("user") + let local = root.appendingPathComponent("local") + + func install(_ url: URL, identifier: String, version: String?) -> URL { + let contents = url.appendingPathComponent("Contents") + try! FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var metadata = ["CFBundleIdentifier": identifier, "CFBundlePackageType": "BNDL"] + metadata["CFBundleShortVersionString"] = version + let data = try! PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0) + try! data.write(to: contents.appendingPathComponent("Info.plist")) + return url + } + + let wallpaper = install(root.appendingPathComponent("Wallpaper.app"), identifier: "dev.patsch.MatrixWallpaper", version: "1.4.0") + let host = Bundle(url: wallpaper)! + func status() -> MatrixVersionStatus { + MatrixVersionStatus.current(hostBundle: host, screenSaverDirectories: [user, local], wallpaperURLs: []) + } + ok(status().state == .missingCompanion, "wallpaper alone is not a version mismatch") + let saver = install(user.appendingPathComponent("Renamed.saver"), identifier: "dev.patsch.MatrixScreenSaver", version: " 1.4.0\n") + ok(status().state == .matching, "renamed saver with whitespace-trimmed matching version is recognized") + _ = install(saver, identifier: "dev.patsch.MatrixScreenSaver", version: "1.3.0") + ok(status().state == .mismatch && status().message.contains("1.3.0") && status().message.contains("1.4.0"), + "replacing installed saver refreshes versions without Bundle metadata caching") + _ = install(saver, identifier: "dev.patsch.MatrixScreenSaver", version: "1.5.0") + ok(status().state == .mismatch, "newer saver also triggers mismatch") + _ = install(saver, identifier: "dev.patsch.MatrixScreenSaver", version: nil) + ok(status().state == .unverified && status().message.contains("Version unavailable"), "missing version is unverified rather than compatible") + _ = install(saver, identifier: "test.unrelated", version: "0.1.0") + ok(status().state == .missingCompanion, "matching filename cannot substitute for bundle identity") + try! Data("not a plist".utf8).write(to: saver.appendingPathComponent("Contents/Info.plist")) + ok(status().state != .mismatch, "malformed metadata never invents a version mismatch") + _ = install(saver, identifier: "dev.patsch.MatrixScreenSaver", version: "1.4.0") + let duplicate = install(local.appendingPathComponent("Other.saver"), identifier: "dev.patsch.MatrixScreenSaver", version: "1.2.0") + ok(status().state == .mismatch && status().screenSavers.contains { $0.url == duplicate.resolvingSymlinksInPath() }, "different installed copies retain their paths") + try! FileManager.default.removeItem(at: duplicate) + try! FileManager.default.createSymbolicLink(at: duplicate, withDestinationURL: saver) + ok(status().state == .matching && status().screenSavers.count == 1, "symlinks to the same installation are deduplicated") + + let saverHostURL = install(root.appendingPathComponent("Loaded.saver"), identifier: "dev.patsch.MatrixScreenSaver", version: "1.1.0") + let saverHost = Bundle(url: saverHostURL)! + _ = saverHost.infoDictionary + _ = install(saverHostURL, identifier: "dev.patsch.MatrixScreenSaver", version: "1.4.0") + let fromSaver = MatrixVersionStatus.current(hostBundle: saverHost, screenSaverDirectories: [], wallpaperURLs: [wallpaper]) + ok(fromSaver.state == .mismatch && fromSaver.screenSavers.first?.version == "1.1.0", + "screen saver compares its loaded metadata rather than the system host or replacement on disk") + let matchingWallpaper = install(root.appendingPathComponent("Matching.app"), identifier: "dev.patsch.MatrixWallpaper", version: "1.1.0") + let multiple = MatrixVersionStatus.current(hostBundle: saverHost, screenSaverDirectories: [], + wallpaperURLs: [matchingWallpaper, wallpaper, matchingWallpaper]) + ok(multiple.state == .mismatch && multiple.wallpapers.count == 2 && multiple.wallpapers.contains { $0.url == wallpaper.resolvingSymlinksInPath() }, + "a matching wallpaper candidate cannot hide a differently versioned copy") + let alone = MatrixVersionStatus.current(hostBundle: saverHost, screenSaverDirectories: [], wallpaperURLs: []) + ok(alone.state == .missingCompanion, "screen saver alone is not a mismatch") + try! FileManager.default.removeItem(at: saver) + ok(status().state != .mismatch, "removing saver clears mismatch even with a dangling symlink") +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift index a88752c..5a62969 100644 --- a/Tests/MatrixScreenSaverTests/main.swift +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -21,6 +21,7 @@ cmdCaptureSceneTests() nativeMatrixRendererTests() wallpaperPlaybackStateTests() sharedWallpaperFrameTests() +matrixVersionStatusTests() print("1..\(testCount)") exit(failCount == 0 ? 0 : 1) diff --git a/Tests/MatrixWallpaperTests/ConsumerHost.swift b/Tests/MatrixWallpaperTests/ConsumerHost.swift index 92eb0a7..78cd7ea 100644 --- a/Tests/MatrixWallpaperTests/ConsumerHost.swift +++ b/Tests/MatrixWallpaperTests/ConsumerHost.swift @@ -22,6 +22,13 @@ func consumerHostTests() { // An unattached-to-window canvas uses display 0, isolating discovery from live wallpaper displays. let container = NSView(frame: NSRect(x: 0, y: 0, width: 437, height: 293)) let view = MatrixScreenSaverView(frame: container.bounds, isPreview: false)! + var mismatch = false + view.readVersionStatus = { + MatrixVersionStatus(wallpapers: [.init(url: URL(fileURLWithPath: "/Wallpaper.app"), version: "1.0", isLoaded: false)], + screenSavers: [.init(url: URL(fileURLWithPath: "/ScreenSaver.saver"), + version: mismatch ? "2.0" : "1.0", isLoaded: true)], + discoveryIncomplete: false) + } container.addSubview(view) view.startAnimation() defer { view.stopAnimation(); view.removeFromSuperview() } @@ -56,6 +63,22 @@ func consumerHostTests() { check(stored("frameIndex", in: renderer, as: Int.self) == frameIndex, "shared frame ticks do not advance native simulation") + let sharedPixels = pixels(of: view) + mismatch = true + view.refreshVersionStatus() + publish(0.4) + wait(0.1) + view.animateOneFrame() + check(stored("wallpaperFrameReader", in: view, as: WallpaperFrameReader.self) == nil && + stored("sharedWallpaperImage", in: view, as: CGImage.self) == nil && + stored("running", in: renderer, as: Bool.self) == false && pixels(of: view) != sharedPixels, + "mismatch tears down the real consumer and replaces shared frames with the error screen") + mismatch = false + view.refreshVersionStatus() + publish(0.75) + check(eventually { view.animateOneFrame(); return stored("sharedWallpaperImage", in: view, as: CGImage.self) != nil }, + "matching versions reacquire wallpaper frames after a consumer mismatch") + let nativeContainers = (1.. NSView in let container = NSView(frame: NSRect(x: 0, y: 0, width: 503 + index, height: 347)) let native = MatrixScreenSaverView(frame: container.bounds, isPreview: false)! @@ -115,4 +138,9 @@ func consumerHostTests() { nativeViews.forEach { $0.stopAnimation() } check(ScreenSyncCoordinator.shared.registeredCount == 0 && ScreenSyncCoordinator.shared.expected == 0, "consumer teardown clears synchronization state") + mismatch = true + view.startAnimation() + check(stored("wallpaperFrameReader", in: view, as: WallpaperFrameReader.self) == nil && + stored("running", in: renderer, as: Bool.self) == false, + "mismatched startup in the real saver host never starts discovery or Matrix playback") } diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 3f585c7..cc96df0 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -112,8 +112,19 @@ func sharedSaverTests() { check(shuffleRadio?.state == .on && randomRadio?.state == .off && sequenceRadio?.state == .off, "shuffled playback is mutually exclusive with the existing modes") checkOptionsLayout(in: controller.configureSheet()) + check(buttons(in: controller.configureSheet().contentView!).first { + $0.action == NSSelectorFromString("openRepository:") + }?.title == "About the project", "options project link uses the requested label") + let donationButton = buttons(in: controller.configureSheet().contentView!).first { + $0.action == NSSelectorFromString("openDonationPage:") + } + check(donationButton?.title == "Buy Me a Coffee" && donationButton?.imagePosition == .imageLeft && + donationButton?.image?.size == NSSize(width: 16, height: 16) && donationButton?.image?.isTemplate == false, + "options donation button uses the full-color favicon at 16 points before its title") + check(donationButton.map { $0.frame.width >= $0.intrinsicContentSize.width + 8 } == true, + "options donation button leaves extra space between its icon and centered label") checkSceneOptions(controller, options: options) - let applyButton = buttons(in: controller.configureSheet().contentView!).first { $0.title == "OK" } + let applyButton = buttons(in: controller.configureSheet().contentView!).first { $0.title == "Save" } check(applyButton != nil, "native options window exposes Apply") applyButton?.performClick(nil) let loaded = MatrixScreenSaverOptions.load(from: defaults) @@ -128,7 +139,7 @@ func sharedSaverTests() { var cancelApplied = false let cancelController = MatrixScreenSaverOptionsSheetController { _ in cancelApplied = true } cancelController.prepare(using: options) - buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Reset to defaults" }!.performClick(nil) + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Restore Defaults" }!.performClick(nil) buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Cancel" }!.performClick(nil) check(!cancelApplied && MatrixScreenSaverOptions.load(from: defaults).commands == options.commands, "main Cancel discards edits without invoking persistence") @@ -288,7 +299,7 @@ private func rainFontResetTests() { check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && picker?.menu?.items.filter { $0.representedObject is String }.allSatisfy { $0.state == .off } == true, "font reset restores the system-font summary and clears custom-font checkmarks") - buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil) + buttons(in: window.contentView!).first { $0.title == "Save" }?.performClick(nil) check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42, "font reset applies the default font without changing other options") } @@ -345,9 +356,11 @@ private func rainGlyphOptionsTests() { check(preview?.font?.fontName == expectedFont, "\(radio.title) preview uses its current rendering font") } window.contentView!.layoutSubtreeIfNeeded() - check(preview != nil && preview!.frame.height > (preview!.font?.pointSize ?? 0) * 1.5 && (rainTab.view as? NSScrollView)?.hasVerticalScroller == true, - "long glyph sets wrap onto multiple lines in a scrollable Rain tab") - buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil) + check(preview != nil && preview!.maximumNumberOfLines == 3 && !(rainTab.view is NSScrollView) + && descendants(of: rainTab.view!).compactMap { $0 as? NSControl }.allSatisfy { + rainTab.view!.bounds.contains($0.convert($0.bounds, to: rainTab.view!)) + }, "film glyph samples and all Rain settings fit without scrolling") + buttons(in: window.contentView!).first { $0.title == "Save" }?.performClick(nil) check(applied?.rainGlyphSet == .resurrections && applied?.rainFonts == ["Menlo"] && applied?.characters == "ATGC", "accepting a film preset keeps custom fonts and characters for switching back") controller.prepare(using: applied!) @@ -364,10 +377,20 @@ private func rainGlyphOptionsTests() { reset?.performClick(nil) check(preview?.font?.fontName == NSFont.monospacedSystemFont(ofSize: 16, weight: .medium).fontName, "font Reset restores the system typeface in the preview") + characters?.stringValue = "" + controller.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: characters)) + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) { + menu.performActionForItem(at: index) + } + window.contentView!.layoutSubtreeIfNeeded() + check((preview?.stringValue.count ?? 0) > 135 + && descendants(of: rainTab.view!).compactMap { $0 as? NSControl }.allSatisfy { + rainTab.view!.bounds.contains($0.convert($0.bounds, to: rainTab.view!)) + }, "large font repertoires remain a bounded sample without hiding Rain settings") var cancelled = false let cancelController = MatrixScreenSaverOptionsSheetController { _ in cancelled = true } cancelController.prepare(using: options) - buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Reset to defaults" }?.performClick(nil) + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Restore Defaults" }?.performClick(nil) let resetRadios: [NSButton] = stored("rainGlyphRadios", in: cancelController) ?? [] check(resetRadios.first?.state == .on, "Reset to defaults restores the default glyph set") buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Cancel" }?.performClick(nil) @@ -396,6 +419,7 @@ private func glyphCacheTests() { check(original === glyph("A"), "frame-rate changes preserve glyph rasters") // These checks exercise plain scalar keys, independent of the user's saved font selection. options.rainFonts = [] + options.rainGlyphSet = .default options.characters = "☃" view.applyWallpaperOptions(options) check(glyph("☃") != nil && glyph("☃", bold: true) != nil, @@ -525,18 +549,32 @@ private func checkOptionsLayout(in window: NSWindow) { let content = window.contentView! content.layoutSubtreeIfNeeded() let size = content.bounds.size - // The Rain tab gained the Fonts section, so the sheet is taller than the original 600 point budget. - check(size.width <= 600 && size.height <= 650, - "options fit in a compact window without screen-sized help text") + check(size.width <= 720 && size.height <= 800, + "options fit all settings in a fixed desktop-sized window") guard let tabs = descendants(of: content).compactMap({ $0 as? NSTabView }).first else { check(false, "options organize settings into native tabs") return } check(tabs.tabViewItems.map(\.label) == ["General", "Intro scenes", "Rain"], "command capture integrates into the current native tab structure") + let general = tabs.tabViewItems[0].view! + let rain = tabs.tabViewItems[2].view! + check(buttons(in: general).contains { $0.title == "Twinkling effect" } + && !buttons(in: rain).contains { $0.title == "Twinkling effect" }, + "twinkle belongs to shared settings because it affects rain and trace") + check(!descendants(of: general).contains { $0.accessibilityLabel() == "Font" } + && descendants(of: rain).contains { $0.accessibilityLabel() == "Font" } + && descendants(of: rain).contains { $0.accessibilityLabel() == "Custom glyphs" }, + "rain owns its font and custom glyph controls") + let save = buttons(in: content).first { $0.title == "Save" } + let cancel = buttons(in: content).first { $0.title == "Cancel" } + check(save?.keyEquivalent == "\r" && cancel?.keyEquivalent == "\u{1b}", + "Save and Cancel retain standard Return and Escape shortcuts") + check(!window.styleMask.contains(.resizable), "options window has a fixed size") for item in tabs.tabViewItems { tabs.selectTabViewItem(item) content.layoutSubtreeIfNeeded() + check(!(item.view is NSScrollView), "\(item.label) exposes all settings without scrolling") let page = (item.view as? NSScrollView)?.documentView ?? item.view! let controls = descendants(of: page).compactMap { $0 as? NSControl } check(!controls.isEmpty && controls.allSatisfy { page.bounds.contains($0.convert($0.bounds, to: page)) }, @@ -574,7 +612,7 @@ private func checkSceneOptions(_ controller: MatrixScreenSaverOptionsSheetContro let groups = descendants(of: page).compactMap { $0 as? NSBox } check(groups.count == 3, "intro scenes have three native fieldsets") guard groups.count == 3 else { return } - let titles = ["Show Neo message scene", "Show custom scene", "Show trace program scene"] + let titles = ["Show Neo message", "Run custom commands", "Show trace program"] check(groups.map { buttons(in: $0).first?.title ?? "" } == titles, "fieldsets follow Neo, custom, trace playback order") for group in groups { @@ -586,13 +624,11 @@ private func checkSceneOptions(_ controller: MatrixScreenSaverOptionsSheetContro zip(stack.arrangedSubviews, stack.arrangedSubviews.dropFirst()).allSatisfy { $0.frame.minY >= $1.frame.maxY } }, "fieldset rows do not overlap") } - let customCopy = "Execute custom commands and show its output as messages. Off by default." let labels = descendants(of: groups[1]).compactMap { $0 as? NSTextField } check(labels.contains { $0.isEditable && $0.accessibilityLabel() == "Command output hold in seconds" && $0.stringValue == "4.5" } && !labels.contains { $0.stringValue == "Typing speed" || $0.accessibilityLabel() == "Custom typing speed" }, "custom fieldset replaces independent typing speed with an output hold control") - check(labels.filter { $0.stringValue == customCopy }.count == 1 - && labels.contains { $0.stringValue == "Commands" }, "custom group has exact description and separate Commands row") + check(labels.contains { $0.stringValue == "Commands" }, "custom group has a separate Commands editor row") check(descendants(of: groups[0]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "Lines" }, "Neo fieldset owns its Lines editor") let speed = descendants(of: page).compactMap { $0 as? NSTextField }.first { $0.isEditable && $0.stringValue == "2" }! @@ -667,12 +703,15 @@ private func checkSceneOptions(_ controller: MatrixScreenSaverOptionsSheetContro } else { check(false, "custom Edit opens a native editor sheet") } - buttons(in: window.contentView!).first { $0.title == "Reset to defaults" }!.performClick(nil) + let originalSize = window.contentView!.bounds.size + buttons(in: window.contentView!).first { $0.title == "Restore Defaults" }!.performClick(nil) + check(window.contentView!.bounds.size == originalSize, "Restore Defaults preserves the fixed window size") check(buttons(in: groups[0]).first!.state == .on && name.isEnabled && buttons(in: groups[1]).first!.state == .off && !customEdit.isEnabled && buttons(in: groups[2]).first!.state == .on && speed.isEnabled, "Reset restores scene enablement and leaves shared speed editable") controller.prepare(using: options) + check(window.contentView!.bounds.size == originalSize, "refreshing options preserves the fixed window size") tabs.selectTabViewItem(at: 0) } diff --git a/Tests/MatrixWallpaperTests/VersionWarningTests.swift b/Tests/MatrixWallpaperTests/VersionWarningTests.swift new file mode 100644 index 0000000..2ea3207 --- /dev/null +++ b/Tests/MatrixWallpaperTests/VersionWarningTests.swift @@ -0,0 +1,356 @@ +import AppKit + +/// Uses real temporary bundle metadata for both native presentation paths. +private final class VersionWarningFixture { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let hostBundle: Bundle + let saverDirectory: URL + + init() { + let appURL = root.appendingPathComponent("Wallpaper.app") + saverDirectory = root.appendingPathComponent("Screen Savers") + Self.write(appURL, identifier: "dev.patsch.MatrixWallpaper", version: "1.4.0") + hostBundle = Bundle(url: appURL)! + installSaver("1.3.0") + } + + deinit { try? FileManager.default.removeItem(at: root) } + + func installSaver(_ version: String?) { + Self.write(saverDirectory.appendingPathComponent("Renamed.saver"), identifier: "dev.patsch.MatrixScreenSaver", version: version) + } + + func read() -> MatrixVersionStatus { + MatrixVersionStatus.current(hostBundle: hostBundle, screenSaverDirectories: [saverDirectory], wallpaperURLs: []) + } + + private static func write(_ url: URL, identifier: String, version: String?) { + let contents = url.appendingPathComponent("Contents") + try! FileManager.default.createDirectory(at: contents, withIntermediateDirectories: true) + var metadata = ["CFBundleIdentifier": identifier, "CFBundlePackageType": "BNDL"] + metadata["CFBundleShortVersionString"] = version + let data = try! PropertyListSerialization.data(fromPropertyList: metadata, format: .xml, options: 0) + try! data.write(to: contents.appendingPathComponent("Info.plist")) + } +} + +/// Checks the shared banner, launch-only alert, menu, icon, and refresh lifecycle. +func versionWarningTests() { + versionOptionsTests() + versionBlockedSaverTests() + versionWallpaperTests() +} + +private func versionOptionsTests() { + let fixture = VersionWarningFixture() + var applied: MatrixScreenSaverOptions? + let controller = MatrixScreenSaverOptionsSheetController(readVersionStatus: fixture.read) { applied = $0 } + controller.prepare(using: MatrixScreenSaverOptions()) + let window = controller.configureSheet() + window.center() + window.makeKeyAndOrderFront(nil) + defer { window.orderOut(nil) } + let banner: NSStackView = stored("versionBanner", in: controller)! + let description: NSTextField = stored("versionDescription", in: controller)! + let heading: NSTextField = stored("versionHeading", in: controller)! + let symbol: NSImageView = stored("versionSymbol", in: controller)! + let name: NSTextField = stored("nameField", in: controller)! + let scroll: NSScrollView = stored("optionsScrollView", in: controller)! + let tabs = scroll.documentView as! NSTabView + let content = window.contentView! + check(!banner.isHidden && heading.stringValue == "Version Mismatch" && + description.stringValue.contains("1.4.0") && description.stringValue.contains("1.3.0"), + "options show an explicit mismatch and both versions") + check(description.isSelectable && description.stringValue.count < 110, + "options keep version information selectable and concise") + check(symbol.image === NSImage(named: NSImage.cautionName), "options use the standard macOS warning triangle") + check(versionDescendants(banner).compactMap { $0 as? NSButton }.map(\.title) == ["View Releases…"], + "the options banner offers only View Releases without a redundant Details button") + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + window.appearance = NSAppearance(named: appearance) + for item in tabs.tabViewItems { + tabs.selectTabViewItem(item) + content.layoutSubtreeIfNeeded() + check(!banner.isHidden && content.bounds.contains(banner.convert(banner.bounds, to: content)) && + banner.convert(banner.bounds, to: content).minY >= scroll.convert(scroll.bounds, to: content).maxY, + "\(item.label) keeps the warning above its scrollable settings in \(appearance.rawValue)") + } + captureVersionView(content, name: "options-\(appearance.rawValue)") + } + let controls = versionDescendants(content).compactMap { $0 as? NSButton } + let save = controls.first { $0.title == "Save" }! + let cancel = controls.first { $0.title == "Cancel" }! + check(save.isEnabled && cancel.isEnabled && save.keyEquivalent == "\r" && cancel.keyEquivalent == "\u{1b}", + "mismatch preserves Save, Cancel, and their standard keyboard equivalents") + check(content.bounds.contains(save.convert(save.bounds, to: content)) && + window.frame.height <= (window.screen?.visibleFrame.height ?? .infinity), + "warning layout keeps actions inside the window and fits the current display") + name.stringValue = "Unsaved Trinity" + fixture.installSaver("1.4.0") + controller.windowDidBecomeKey(Notification(name: NSWindow.didBecomeKeyNotification, object: window)) + check(banner.isHidden && name.stringValue == "Unsaved Trinity", "reactivating options clears resolved mismatch without losing edits") + fixture.installSaver(nil) + _ = controller.configureSheet() + check(!banner.isHidden && heading.stringValue == "Version Unavailable" && description.stringValue.contains("Version unavailable"), + "unreadable version is explained in options without claiming a mismatch") + fixture.installSaver("1.3.0") + _ = controller.configureSheet() + save.performClick(nil) + check(applied?.name == "Unsaved Trinity", "Save still delivers edited options during mismatch") + var cancelledApply = false + let cancelling = MatrixScreenSaverOptionsSheetController(readVersionStatus: fixture.read) { _ in cancelledApply = true } + cancelling.prepare(using: MatrixScreenSaverOptions()) + let cancelWindow = cancelling.configureSheet() + versionDescendants(cancelWindow.contentView!).compactMap { $0 as? NSButton }.first { $0.title == "Cancel" }?.performClick(nil) + check(!cancelledApply, "Cancel during mismatch does not apply preferences") +} + +private func versionWallpaperTests() { + let fixture = VersionWarningFixture() + let suite = "MatrixVersionWarnings.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let desktop = DesktopWallpaperFixture() + var alerts = 0 + var host: WallpaperApp! + host = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller(), readVersionStatus: fixture.read, + presentVersionAlert: { alert in + alerts += 1 + check(alert.messageText == "Version Mismatch" && alert.informativeText.count < 110 && + alert.informativeText.contains("1.4.0") && alert.informativeText.contains("1.3.0") && + alert.buttons.map(\.title) == ["View Releases…", "Close"], + "short error dialog shows versions and offers releases or Close") + check(alert.alertStyle == .warning && alert.icon === NSImage(named: NSImage.cautionName), + "version dialog uses the standard macOS warning triangle") + do { + let dismiss = Timer(timeInterval: 0.1, repeats: true) { _ in + guard NSApp.modalWindow === alert.window else { return } + if alerts == 1 { captureVersionView(alert.window.contentView!, name: "startup-alert") } + NSApp.stopModal(withCode: .alertSecondButtonReturn) + } + RunLoop.main.add(dismiss, forMode: .modalPanel) + defer { dismiss.invalidate() } + let response = alert.runModal() + alert.window.orderOut(nil) + return response + } + }) + host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + defer { + host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + host = nil + } + let status: NSStatusItem = stored("statusItem", in: host!)! + let menu = status.menu! + let warning = menu.items.first { $0.title == "Version Mismatch…" }! + check(eventually(5) { alerts == 1 }, "mismatched launch presents one automatic warning") + check(eventually { + let windows: [DesktopWindow] = stored("windows", in: host!) ?? [] + return windows.count == NSScreen.screens.count && windows.allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true + } + }, "mismatch keeps desktop surfaces available for the moving error symbol") + check((stored("windows", in: host!, as: [DesktopWindow].self) ?? []).allSatisfy { + guard let view = $0.contentView as? MatrixScreenSaverView, + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view) else { return false } + return stored("running", in: renderer, as: Bool.self) == false && view.shareWallpaperFrames() == nil + }, "version mismatch blocks Matrix simulation and frame sharing") + check(menu.items.filter { !$0.isHidden }.first === warning && warning.image != nil && + status.button?.accessibilityLabel() == "Matrix Wallpaper — version mismatch", + "mismatch leads the menu and exposes a non-color status warning to accessibility") + let visibleItems = menu.items.filter { !$0.isHidden } + check(visibleItems.prefix(3).map(\.title) == ["Version Mismatch…", "", "Pause Animation"] && visibleItems[1].isSeparatorItem, + "the mismatch menu section contains exactly one item followed by a separator") + check(warning.image?.isTemplate == false && warning.image?.size == NSSize(width: 18, height: 18), + "the first menu item uses a full-color warning image sized for the menu") + if warning.responds(to: NSSelectorFromString("setPreferredImageVisibility:")) { + check(warning.value(forKey: "preferredImageVisibility") as? Int == 1, + "macOS 27 is instructed to keep the mismatch icon visible") + } + captureVersionMenu(menu) + let warningImage = status.button?.image + check(warningImage != nil && status.button?.toolTip == nil && warning.toolTip == nil, + "persistent error icon has no menu or status-button hover text") + menu.delegate?.menuWillOpen?(menu) + let timer: Timer = stored("versionRefreshTimer", in: host!)! + timer.fire() + check(alerts == 1, "menu opening and periodic refresh do not repeat startup warning") + menu.performActionForItem(at: menu.index(of: warning)) + check(alerts == 2, "menu warning can explicitly reopen version details") + check((stored("windows", in: host!, as: [DesktopWindow].self) ?? []).allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true + }, "dismissing the menu error preserves the moving error screen") + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + wait(1.2) + fixture.installSaver("1.4.0") + timer.fire() + check((stored("windows", in: host!, as: [DesktopWindow].self) ?? []).allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false + }, "resolving a mismatch preserves manual pause") + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check(warning.isHidden && status.button?.image !== warningImage && status.button?.toolTip == nil, + "periodic refresh restores the normal icon and hides warning after matching update") + check(eventually(0.3) { + for window in stored("windows", in: host!, as: [DesktopWindow].self) ?? [] { + guard let view = window.contentView as? MatrixScreenSaverView, + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view) else { return false } + view.animateOneFrame() + if stored("running", in: renderer, as: Bool.self) != true { return false } + } + return true + }, "Resume after resolving a paused mismatch does not wait through the old pause again") + fixture.installSaver(nil) + timer.fire() + check(warning.isHidden && alerts == 2, "unknown version is not a confirmed menu mismatch or repeated alert") + fixture.installSaver("1.3.0") + timer.fire() + check(!warning.isHidden && alerts == 2, "new mismatch while running updates persistent warnings without another startup alert") + check((stored("windows", in: host!, as: [DesktopWindow].self) ?? []).allSatisfy { + guard let view = $0.contentView as? MatrixScreenSaverView, + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view) else { return false } + return stored("running", in: renderer, as: Bool.self) == false && view.shareWallpaperFrames() == nil + }, "a mismatch found during playback stops Matrix and prevents shared frames") + host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + check(!timer.isValid, "termination invalidates version refresh timer") + + var cancelledAlerts = 0 + let cancelled = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller(), readVersionStatus: fixture.read, + presentVersionAlert: { _ in cancelledAlerts += 1; return .alertSecondButtonReturn }) + cancelled.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + cancelled.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + wait(0.1) + check(cancelledAlerts == 0, "Quit before queued startup suppresses the pending warning") + + var quitDuringAlert: WallpaperApp! + var modalReturned = false + quitDuringAlert = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller(), readVersionStatus: fixture.read, + presentVersionAlert: { alert in + let quit = Timer(timeInterval: 0.1, repeats: true) { _ in + guard NSApp.modalWindow === alert.window else { return } + quitDuringAlert.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + } + RunLoop.main.add(quit, forMode: .modalPanel) + defer { quit.invalidate() } + let result = alert.runModal() + modalReturned = true + return result + }) + quitDuringAlert.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + check(eventually { modalReturned } && NSApp.modalWindow == nil && + (stored("windows", in: quitDuringAlert!, as: [DesktopWindow].self) ?? []).isEmpty, + "Quit during the warning unwinds its modal loop without starting wallpaper") + quitDuringAlert = nil + + for companion in ["matching", "missing"] { + if companion == "matching" { fixture.installSaver("1.4.0") } + else { try! FileManager.default.removeItem(at: fixture.saverDirectory) } + var unexpectedAlerts = 0 + let normal = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller(), readVersionStatus: fixture.read, + presentVersionAlert: { _ in unexpectedAlerts += 1; return .alertSecondButtonReturn }) + normal.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + check(eventually { (stored("windows", in: normal, as: [DesktopWindow].self) ?? []).count == NSScreen.screens.count } + && unexpectedAlerts == 0, "\(companion) companion launches without a version alert") + normal.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + } +} + +/// The real saver view must block before starting either its renderer or a wallpaper consumer. +private func versionBlockedSaverTests() { + for preview in [true, false] { + let fixture = VersionWarningFixture() + let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 320, height: 180), isPreview: preview)! + view.readVersionStatus = fixture.read + let container = NSView(frame: view.frame) + container.addSubview(view) + defer { view.stopAnimation(); view.removeFromSuperview() } + view.startAnimation() + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view)! + let before = errorBitmap(view) + for _ in 0..<90 { view.animateOneFrame() } + let after = errorBitmap(view) + if let directory = ProcessInfo.processInfo.environment["MATRIX_VERSION_SNAPSHOTS"] { + let url = URL(fileURLWithPath: directory).appendingPathComponent("blocked-saver-\(preview).png") + try! after.representation(using: .png, properties: [:])!.write(to: url) + } + let black = after.colorAt(x: 0, y: 0)!.usingColorSpace(.deviceRGB)! + let hasYellow = (0.. 0.6 && color.greenComponent > 0.4 && color.blueComponent < 0.3 + } + } + check(stored("running", in: renderer, as: Bool.self) == false && + stored("wallpaperFrameReader", in: view, as: WallpaperFrameReader.self) == nil, + "saver preview=\(preview) blocks native and shared Matrix playback") + check(black.redComponent == 0 && black.greenComponent == 0 && black.blueComponent == 0 && hasYellow, + "saver preview=\(preview) renders a yellow warning triangle on black") + check((before.representation(using: .png, properties: [:]) == after.representation(using: .png, properties: [:])) + == NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + "error symbol moves unless Reduce Motion is enabled") + view.applyWallpaperOptions(MatrixScreenSaverOptions()) + check(stored("running", in: renderer, as: Bool.self) == false, + "editing settings cannot bypass the version block") + fixture.installSaver("1.4.0") + view.refreshVersionStatus() + check(stored("running", in: renderer, as: Bool.self) == true, + "saver resumes when installed versions match") + } +} + +private func errorBitmap(_ view: NSView) -> NSBitmapImageRep { + let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(view.bounds.width), pixelsHigh: Int(view.bounds.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap) + view.draw(view.bounds) + NSGraphicsContext.restoreGraphicsState() + return bitmap +} + +private func versionDescendants(_ view: NSView) -> [NSView] { + view.subviews.flatMap { [$0] + versionDescendants($0) } +} + +/// Captures the opened native menu, where macOS can suppress otherwise assigned item images. +private func captureVersionMenu(_ menu: NSMenu) { + guard let directory = ProcessInfo.processInfo.environment["MATRIX_VERSION_SNAPSHOTS"], + let screen = NSScreen.main else { return } + for appearance in [NSAppearance.Name.aqua, .darkAqua] { + let previous = menu.appearance + menu.appearance = NSAppearance(named: appearance) + defer { menu.appearance = previous } + // Drain the modal dismissal event before entering menu tracking. + wait(0.2) + var captured = false + let capture = Timer(timeInterval: 0.5, repeats: false) { _ in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + process.arguments = ["-x", "-R", "30,80,340,350", URL(fileURLWithPath: directory).appendingPathComponent("menu-\(appearance.rawValue).png").path] + try! process.run() + process.waitUntilExit() + captured = process.terminationStatus == 0 + check(process.terminationStatus == 0, "opened native menu snapshot captured in \(appearance.rawValue)") + menu.cancelTracking() + } + RunLoop.main.add(capture, forMode: .eventTracking) + menu.popUp(positioning: nil, at: NSPoint(x: screen.visibleFrame.minX + 40, y: screen.visibleFrame.maxY - 80), in: nil) + capture.invalidate() + check(captured, "menu stayed open long enough to inspect its warning icon") + } +} + +/// Optional native snapshots support visual checks without modifying installed applications. +private func captureVersionView(_ view: NSView, name: String) { + guard let directory = ProcessInfo.processInfo.environment["MATRIX_VERSION_SNAPSHOTS"], + let window = view.window else { return } + view.displayIfNeeded() + window.displayIfNeeded() + let url = URL(fileURLWithPath: directory).appendingPathComponent(name + ".png") + let capture = Process() + capture.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + capture.arguments = ["-x", "-o", "-l", String(window.windowNumber), url.path] + try! capture.run() + capture.waitUntilExit() + check(capture.terminationStatus == 0, "native window snapshot captured: \(name)") +} diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index dd1fcd0..a2afde5 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -15,19 +15,30 @@ func wallpaperAppTests() { host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) defer { host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } let status: NSStatusItem? = stored("statusItem", in: host) - let controls = status?.menu?.items.first?.view as? NSStackView - let buttons = controls?.arrangedSubviews.compactMap { $0 as? NSButton } ?? [] - let stopItem = buttons.first - let pauseItem = buttons.last - check(controls?.orientation == .horizontal && buttons.map(\.title) == ["Stop", "Pause"] && - buttons.allSatisfy { $0.imagePosition == .noImage && $0.bezelStyle == .rounded }, - "launch shows Stop and Pause as text push buttons in one horizontal menu row") let menuItems = status?.menu?.items ?? [] + let donationItem = menuItems.first { $0.action == NSSelectorFromString("openDonationPage:") } + check(donationItem?.title == "Buy Me a Coffee" && donationItem?.image?.size == NSSize(width: 16, height: 16) && + donationItem?.image?.isTemplate == false, + "donation menu uses the full-color favicon at 16 points with the requested title") + if let donationItem, donationItem.responds(to: NSSelectorFromString("preferredImageVisibility")) { + check(donationItem.value(forKey: "preferredImageVisibility") as? Int == 1, + "donation menu keeps the favicon visible on macOS 27") + } + let stopItem = menuItems.first { $0.action == NSSelectorFromString("togglePlayback:") } + let pauseItem = menuItems.first { $0.action == NSSelectorFromString("togglePause:") } + check(menuItems.filter { !$0.isHidden }.prefix(2).map(\.title) == ["Pause Animation", "Stop Wallpaper"] && + menuItems.allSatisfy { $0.view == nil } && pauseItem?.image != nil && stopItem?.image != nil, + "playback commands lead the menu as native items with symbols") + let settingsItem = menuItems.first { $0.action == NSSelectorFromString("showOptions:") } + check(settingsItem?.title == "Settings…" && settingsItem?.keyEquivalent == "," && + settingsItem?.keyEquivalentModifierMask == .command, + "Settings uses the standard macOS label and Command-comma shortcut") if let loginIndex = menuItems.firstIndex(where: { $0.title.hasPrefix("Launch at Login") }), let syncIndex = menuItems.firstIndex(where: { $0.title == "Sync with Screen Saver" }) { - check(syncIndex == loginIndex + 1 && loginIndex > 0 && menuItems[loginIndex - 1].isSeparatorItem && + check(syncIndex == loginIndex + 1 && loginIndex > 1 && menuItems[loginIndex - 1] === settingsItem && + menuItems[loginIndex - 2].isSeparatorItem && syncIndex + 1 < menuItems.count && menuItems[syncIndex + 1].isSeparatorItem, - "Sync with Screen Saver sits below Launch at Login between dividers") + "settings, login, and screen saver sync form one preference group") } else { check(false, "menu contains login and screen saver sync controls") } @@ -47,9 +58,9 @@ func wallpaperAppTests() { let workspace = NSWorkspace.shared.notificationCenter wait(0.3) - check(NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil), "Pause menu action is connected") - check(pauseItem?.title == "Resume" && stopItem?.title == "Stop" && pauseItem?.isEnabled == true, - "paused menu offers Resume beside Stop") + if let pauseItem, let menu = status?.menu { menu.performActionForItem(at: menu.index(of: pauseItem)) } + check(pauseItem?.title == "Resume Animation" && stopItem?.title == "Stop Wallpaper" && pauseItem?.isEnabled == true, + "native Pause command freezes playback and offers Resume Animation") check(originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false }, "Pause freezes every display without hiding its wallpaper") if let source = originalWindows.first?.contentView { @@ -67,7 +78,7 @@ func wallpaperAppTests() { check(desktopWindowCount() == 0 && originalWindows.allSatisfy { ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false }, "Resume cannot override system sleep") workspace.post(name: NSWorkspace.didWakeNotification, object: nil) - check(pauseItem?.title == "Pause" && originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true }, + check(pauseItem?.title == "Pause Animation" && originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true }, "Resume continues the original views after the system suspension ends") workspace.post(name: NSWorkspace.willSleepNotification, object: nil) check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "temporary sleep retains black desktops and backups") @@ -99,7 +110,7 @@ func wallpaperAppTests() { wait(0.1) first = reader?.frame(displayID: displayID, pointSize: source.bounds.size) } while first == nil && Date() < deadline - check(first != nil && source.isAnimating && pauseItem?.title == "Resume", + check(first != nil && source.isAnimating && pauseItem?.title == "Resume Animation", "saver discovery advances the paused scene without clearing manual pause") let distributed = DistributedNotificationCenter.default() distributed.postNotificationName(Notification.Name("com.apple.screensaver.willstart"), object: nil, @@ -113,7 +124,7 @@ func wallpaperAppTests() { "hidden wallpaper publishes advancing frames for the saver") NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) - check(source.isAnimating && pauseItem?.title == "Resume" && desktopWindowCount() == 0, + check(source.isAnimating && pauseItem?.title == "Resume Animation" && desktopWindowCount() == 0, "changing manual pause during saver playback preserves the active shared scene") let sharedURL = source.shareWallpaperFrames()! NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil) @@ -136,7 +147,7 @@ func wallpaperAppTests() { distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil, userInfo: nil, deliverImmediately: true) wait(0.2) - check(!source.isAnimating && pauseItem?.title == "Resume" && + check(!source.isAnimating && pauseItem?.title == "Resume Animation" && Set(originalWindows.filter(\.isVisible).map(ObjectIdentifier.init)) == originalIDs, "saver exit immediately reveals the preserved paused wallpaper before its lease expires") let frozen = pixels(of: source) @@ -189,7 +200,7 @@ func wallpaperAppTests() { distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil, userInfo: nil, deliverImmediately: true) wait(0.2) - check(!source.isAnimating && pauseItem?.title == "Resume" && + check(!source.isAnimating && pauseItem?.title == "Resume Animation" && Set(NSApp.windows.filter { $0 is DesktopWindow && $0.isVisible }.map(ObjectIdentifier.init)) == originalIDs, "saver exit after lease expiry keeps the same wallpaper views paused") NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) @@ -199,8 +210,8 @@ func wallpaperAppTests() { workspace.post(name: NSWorkspace.willSleepNotification, object: nil) check(NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil), "Stop menu action is connected") - check(stopItem?.title == "Start" && pauseItem?.title == "Pause" && pauseItem?.isEnabled == false, - "Stop clears manual pause and disables Pause beside Start") + check(stopItem?.title == "Start Wallpaper" && pauseItem?.title == "Pause Animation" && pauseItem?.isEnabled == false, + "Stop offers Start Wallpaper and leaves Pause Animation visible but disabled") NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) check(desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg", "Stop restores desktop images even during suspension") @@ -264,8 +275,8 @@ private func wallpaperEditingShortcutTests(controller: MatrixScreenSaverOptionsS } let original = field.stringValue let wasEnabled = field.isEnabled - let root: NSStackView? = stored("rootStack", in: controller) - let tabs = root?.arrangedSubviews.compactMap { $0 as? NSTabView }.first + let scroll: NSScrollView? = stored("optionsScrollView", in: controller) + let tabs = scroll?.documentView as? NSTabView let selectedTab = tabs?.selectedTabViewItem tabs?.selectTabViewItem(at: 1) defer { @@ -598,8 +609,8 @@ private func wallpaperIconTests() { let menu = NSBitmapImageRep(cgImage: context.makeImage()!) let stem = (12..<60).map { menu.colorAt(x: 20, y: $0)! } check(menu.colorAt(x: 0, y: 0)!.alphaComponent == 0 && - stem.contains { $0.alphaComponent == 0 } && stem.contains { $0.alphaComponent > 0.9 }, - "menu icon has transparent background and scanline gaps through the M") + stem.allSatisfy { $0.alphaComponent > 0.9 }, + "menu icon has transparent background and a solid, high-contrast M stem") check(stem.allSatisfy { $0.redComponent == $0.greenComponent && $0.greenComponent == $0.blueComponent }, "menu icon is monochrome for system template tinting") } diff --git a/Tests/MatrixWallpaperTests/main.swift b/Tests/MatrixWallpaperTests/main.swift index a21b395..fc76176 100644 --- a/Tests/MatrixWallpaperTests/main.swift +++ b/Tests/MatrixWallpaperTests/main.swift @@ -26,6 +26,10 @@ func wait(_ seconds: TimeInterval) { let app = NSApplication.shared app.setActivationPolicy(.accessory) app.finishLaunching() +if CommandLine.arguments.contains("--version-warning-tests") { + versionWarningTests() + exit(failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE) +} if CommandLine.arguments.contains("--consumer-host") { consumerHostTests() exit(failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE) @@ -77,6 +81,7 @@ check(!window.isVisible, "closing removes the desktop window") sharedSaverTests() sharedCompositionTests() wallpaperAppTests() +versionWarningTests() desktopWallpaperTests() loginItemTests() exit(failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE) diff --git a/Tools/GenerateWallpaperIcons.swift b/Tools/GenerateWallpaperIcons.swift index c9eab21..cd1c71c 100644 --- a/Tools/GenerateWallpaperIcons.swift +++ b/Tools/GenerateWallpaperIcons.swift @@ -132,11 +132,12 @@ private var bounds = CGRect(x: 0, y: 0, width: 18, height: 18) private let context = CGContext(consumer: consumer, mediaBox: &bounds, nil)! context.beginPDFPage(nil) context.addPath(glyph(in: CGRect(x: 2, y: 2, width: 14, height: 14))) -context.clip() +// Solid, slightly heavier strokes keep the small template legible at menu-bar size. context.setFillColor(NSColor.black.cgColor) -for y in stride(from: 2.0, to: 16.0, by: 2.0) { - context.fill(CGRect(x: 0, y: y, width: 18, height: 1.35)) -} +context.setStrokeColor(NSColor.black.cgColor) +context.setLineWidth(0.45) +context.setLineJoin(.round) +context.drawPath(using: .fillStroke) context.endPDFPage() context.closePDF() try (pdf as Data).write(to: resources.appendingPathComponent("MenuBarIcon.pdf")) diff --git a/build-wallpaper.sh b/build-wallpaper.sh index 24f6489..6a27134 100755 --- a/build-wallpaper.sh +++ b/build-wallpaper.sh @@ -17,6 +17,7 @@ swiftc -O -module-name MatrixWallpaper \ -framework Foundation -framework AppKit -framework ScreenSaver -framework ServiceManagement \ "$SOURCE_DIR/TerminalSupport.swift" \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ + "$SOURCE_DIR/MatrixVersionStatus.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ "$SOURCE_DIR/CmdCaptureScene.swift" \ @@ -39,6 +40,7 @@ cp "$ROOT_DIR/Resources/Wallpaper-Info.plist" "$BUNDLE_DIR/Contents/Info.plist" mkdir -p "$BUNDLE_DIR/Contents/Resources/Fonts" cp "$ROOT_DIR/Resources/Fonts/"* "$BUNDLE_DIR/Contents/Resources/Fonts/" cp "$ROOT_DIR/Resources/WallpaperIcon.icns" "$ROOT_DIR/Resources/MenuBarIcon.pdf" "$BUNDLE_DIR/Contents/Resources/" +cp "$ROOT_DIR/Resources/BuyMeACoffee.png" "$BUNDLE_DIR/Contents/Resources/BuyMeACoffee.png" /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist" /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist" plutil -lint "$BUNDLE_DIR/Contents/Info.plist" >/dev/null diff --git a/build.sh b/build.sh index 4db86f1..161b2ed 100755 --- a/build.sh +++ b/build.sh @@ -34,6 +34,7 @@ swiftc \ -framework ScreenSaver \ "$SOURCE_DIR/TerminalSupport.swift" \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ + "$SOURCE_DIR/MatrixVersionStatus.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ "$SOURCE_DIR/CmdCaptureScene.swift" \ @@ -47,6 +48,7 @@ swiftc \ cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" cp -R "$INPUT_RESOURCES_DIR/Fonts" "$RESOURCES_DIR/Fonts" +cp "$INPUT_RESOURCES_DIR/BuyMeACoffee.png" "$RESOURCES_DIR/BuyMeACoffee.png" for f in Preview.png "Preview@2x.png"; do if [[ -f "$INPUT_RESOURCES_DIR/$f" ]]; then cp "$INPUT_RESOURCES_DIR/$f" "$RESOURCES_DIR/$f" diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..d084c0b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,50 @@ +# Developer notes + +## Wallpaper installer + +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`. + +## Screen saver synchronization + +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. + +## Component version checks + +If Matrix Wallpaper and Matrix Screen Saver have different release versions, an error +appears above the options tabs, on the menu-bar icon, and at the top of its menu. +The wallpaper also shows an alert once per launch. Matrix playback is blocked in both +components: a yellow warning triangle moves slowly over a black screen instead. With Reduce +Motion enabled, the symbol stays still. Settings remain editable. Choose **View Releases…** +and install matching versions to resume playback. +Checks refresh when opening settings or the menu, and approximately once a minute during +playback. A missing companion is not a mismatch; unreadable version information +is explained in settings. Checks compare the loaded host with installed companion bundles, +so another process may still need restarting after an update. Older releases do not gain +these warnings until they are updated. + +## Desktop wallpaper lifecycle + +While Matrix Wallpaper runs, it saves each display's desktop image and display options, +then sets the underlying wallpaper to plain black. The menu starts with **Pause Animation** +and **Stop Wallpaper**. **Pause Animation** freezes the visible frame; **Resume Animation** 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 Wallpaper** 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 Wallpaper** 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. diff --git a/docs/plans/2026-11-10-1812-project-roadmap-plan.md b/docs/plans/2026-11-10-1812-project-roadmap-plan.md new file mode 100644 index 0000000..500e29b --- /dev/null +++ b/docs/plans/2026-11-10-1812-project-roadmap-plan.md @@ -0,0 +1,67 @@ +# Matrix Screen Saver and Wallpaper — Project Roadmap +Created: 2026-11-10 + +This roadmap records future work for the native macOS screen saver and animated wallpaper. It does not authorize implementation or set release dates. Feature-specific implementation plans remain in this directory. + +## Current foundation + +- Native Swift/AppKit screen saver and wallpaper app share the Matrix animation and options. +- `NativeMatrixRenderer.swift` supplies the falling glyphs and intro scenes. +- `SharedWallpaperCompositor.swift` already uses Metal and Core Image for GPU-assisted wallpaper compositing. This is not a full 3D scene renderer. + +## Planned: GPU-accelerated 3D Matrix hallway scene + +### Goal + +Render the scene from *The Matrix* in which three Agents stand in a hallway aiming their guns toward the camera. Use the project's own animated falling glyphs as textures across the walls and throughout the scene, rather than relying on a prerecorded video or baked-in code texture. + +The goal is a real 3D scene with depth and perspective, available in both the screen saver and animated wallpaper. Keep the existing 2D rain available. + +### Milestones + +1. **Find a suitable 3D model.** + - Look for the complete hallway scene with all three Agents in the intended poses and camera composition. + - Check model quality, UV mapping, material separation, and suitability for real-time rendering. + - Verify permission to modify and redistribute the model and its textures with both products. Record the source, author, license, and required attribution; a downloadable model is not automatically redistributable. + - If no suitable complete scene is available, bring back options for assembling licensed assets or commissioning a model before changing the intended scene or spending money. + - Deliverable: a model shortlist with reference images, licensing evidence, and an asset recommendation. No model has been selected yet. + +2. **Validate native, GPU-accelerated 3D rendering.** + - Evaluate a native macOS rendering path, taking the existing Metal integration into account. Choose the framework after checking compatibility with both the `.saver` host and wallpaper app. + - Start with a small scene to verify model loading, camera projection, lighting, depth, and rendering at display backing resolution. + - Measure frame time, GPU/CPU use, memory, and energy impact on representative supported hardware and multiple displays before setting performance targets. + - Deliverable: a rendering proof of concept and a technical implementation plan, not a rewrite of the existing renderer. + +3. **Use our falling glyphs as live scene textures.** + - Reuse the project's glyph artwork and animation rather than substituting someone else's Matrix-code effect. + - Map the animated glyphs onto the hallway walls, floor, ceiling, and Agent/model surfaces to achieve the code-covered scene. Check orientation, scale, seams, and readability on each surface. + - The glyphs must follow the 3D surfaces and perspective, not act as a flat overlay in front of the camera. + - Reuse textures and rendering resources where possible; measure the cost of updating the live glyph textures. + - Deliverable: the chosen model rendered with the project's animated glyph textures. + +4. **Integrate and verify the hallway scene.** + - Add the scene to the shared playback/options flow without removing existing rain or intro scenes. + - Preserve Start, Stop, Pause, Quit, sleep/wake, display synchronization, and desktop-wallpaper restoration. + - Keep playback local after installation; bundle approved assets rather than fetching models at runtime. + - Verify the scene in the actual screen saver host, preview host, and wallpaper app, including display changes and resource cleanup. + - Deliverable: a user-selectable scene with visual, lifecycle, and performance checks. + +### Acceptance criteria + +- Three Agents stand in the hallway aiming toward the camera, with composition reviewed against the intended movie scene. +- Geometry, depth, lighting, and animated materials are rendered using hardware-accelerated 3D graphics. +- The project's falling glyphs animate across scene surfaces, including walls and other visible geometry. +- Both products render the scene without breaking existing playback, synchronization, or desktop restoration. +- All bundled models and textures have documented modification and redistribution rights. +- Frame-rate and resource-use targets are agreed from the rendering measurements and verified before release. + +### Decisions still open + +- Exact movie reference frame and model source. +- Native 3D framework, asset format, and any necessary model conversion. +- Supported-hardware performance budget and behavior when the 3D rendering path is unavailable. +- Whether the first version keeps the camera and Agent poses fixed or adds motion. Motion beyond the falling glyphs is not yet specified. + +## Next planning step + +Source the model candidates and confirm the reference composition. Use those findings and the rendering proof of concept to write a bounded implementation plan before starting production changes. diff --git a/docs/wallpaper-menubar.jpg b/docs/wallpaper-menubar.jpg index df079e1..b6ea5dd 100644 Binary files a/docs/wallpaper-menubar.jpg and b/docs/wallpaper-menubar.jpg differ diff --git a/tests.sh b/tests.sh index 3a04d22..14d4639 100755 --- a/tests.sh +++ b/tests.sh @@ -17,6 +17,7 @@ swiftc \ -framework AppKit \ -framework ScreenSaver \ "$SRC/TerminalSupport.swift" \ + "$SRC/MatrixVersionStatus.swift" \ "$SRC/Xorshift64.swift" \ "$SRC/NeoMessageScene.swift" \ "$SRC/CmdCaptureScene.swift" \ @@ -32,6 +33,7 @@ swiftc \ "$TESTS/NativeMatrixRendererTests.swift" \ "$TESTS/WallpaperPlaybackStateTests.swift" \ "$TESTS/SharedWallpaperFrameTests.swift" \ + "$TESTS/MatrixVersionStatusTests.swift" \ "$TESTS/main.swift" \ -o "$TEST_BIN" diff --git a/wallpaper-tests.sh b/wallpaper-tests.sh index 2f833fa..069f016 100755 --- a/wallpaper-tests.sh +++ b/wallpaper-tests.sh @@ -13,6 +13,7 @@ HOST_DIR="$TEST_CONTENTS/MacOS" TEST_BIN="$HOST_DIR/matrix-wallpaper-tests" mkdir -p "$HOST_DIR" "$TEST_CONTENTS/Resources" cp -R "$ROOT_DIR/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts" +cp "$ROOT_DIR/Resources/BuyMeACoffee.png" "$TEST_CONTENTS/Resources/BuyMeACoffee.png" OPTIMIZATION="-Onone" if [[ "${1:-}" == "--gpu-composition-benchmark" || "${1:-}" == "--composition-benchmark" ]]; then @@ -22,6 +23,7 @@ fi swiftc "$OPTIMIZATION" -framework Foundation -framework AppKit -framework ScreenSaver -framework ServiceManagement \ "$SOURCE_DIR/TerminalSupport.swift" \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ + "$SOURCE_DIR/MatrixVersionStatus.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ "$SOURCE_DIR/CmdCaptureScene.swift" \ @@ -42,12 +44,13 @@ swiftc "$OPTIMIZATION" -framework Foundation -framework AppKit -framework Screen "$TEST_DIR/SharedCompositionTests.swift" \ "$TEST_DIR/GPUCompositionTests.swift" \ "$TEST_DIR/WallpaperAppTests.swift" \ + "$TEST_DIR/VersionWarningTests.swift" \ "$TEST_DIR/LoginItemTests.swift" \ "$TEST_DIR/DesktopWallpaperTests.swift" \ "$TEST_DIR/main.swift" \ -o "$TEST_BIN" -if [[ "${1:-}" == "--composition-benchmark" || "${1:-}" == "--composition-tests" || "${1:-}" == "--gpu-composition-tests" || "${1:-}" == "--gpu-composition-benchmark" ]]; then +if [[ "${1:-}" == "--composition-benchmark" || "${1:-}" == "--composition-tests" || "${1:-}" == "--gpu-composition-tests" || "${1:-}" == "--gpu-composition-benchmark" || "${1:-}" == "--version-warning-tests" ]]; then "$TEST_BIN" "$1" exit fi