From 1913ca561da19a6b82de0dfcd1dc807519a8b122 Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 21:58:47 +1100 Subject: [PATCH 01/17] feat: add Fonts option to pick installed fonts for the rain glyphs - render rain with the chosen installed fonts, mixed evenly; intro text stays in the system font - add a Fonts section to the Rain tab with an installed-fonts pull-down and a Show all fonts toggle - alias ASCII glyphs to private-use scalars so digits and symbols come from the rain font - persist the selection in defaults (empty keeps the system font) and cap it at 8 fonts --- .../MatrixScreenSaverOptions.swift | 109 ++++++++++++++- .../MatrixScreenSaverView.swift | 33 ++++- .../NativeMatrixRenderer.swift | 130 +++++++++++++++++- 3 files changed, 260 insertions(+), 12 deletions(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index b465a3c..94ace21 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -17,6 +17,8 @@ struct MatrixScreenSaverOptions: Equatable { static let frameRate = "FrameRate" static let errorRate = "ErrorRate" static let characters = "Characters" + /// Defaults key for the selected rain font families. + static let rainFonts = "RainFonts" static let scanLinesIntensity = "ScanLinesIntensity" static let scanLinesVertical = "ScanLinesVertical" } @@ -69,6 +71,12 @@ struct MatrixScreenSaverOptions: Equatable { static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." static let errorRateDescription = "Character change rate factor. Default: 1.0." static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default." + /// Help text shown under the Fonts picker. + static let rainFontsDescription = "Fonts for the falling glyphs. Pick several to mix them. None keeps the built-in glyphs. Intro text always uses the system font." + /// Default selection: no fonts, which keeps the built-in glyphs and system font. + static let defaultRainFonts: [String] = [] + /// Maximum number of rain fonts that can be mixed. + static let maximumRainFonts = 8 static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled @@ -85,6 +93,8 @@ struct MatrixScreenSaverOptions: Equatable { var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters + /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs. + var rainFonts = defaultRainFonts var scanLinesIntensity = defaultScanLinesIntensity var scanLinesVertical = defaultScanLinesVertical @@ -114,6 +124,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + rainFonts: rainFonts, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview ) @@ -157,6 +168,14 @@ struct MatrixScreenSaverOptions: Equatable { .map { String($0) } .joined() }(), + rainFonts: { + var seen = Set() + return rainFonts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && seen.insert($0).inserted } + .prefix(Self.maximumRainFonts) + .map { $0 } + }(), scanLinesIntensity: max(0, min(1, scanLinesIntensity)), scanLinesVertical: scanLinesVertical ) @@ -179,6 +198,7 @@ struct MatrixScreenSaverOptions: Equatable { Keys.frameRate: defaultFrameRate, Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, + Keys.rainFonts: defaultRainFonts, Keys.scanLinesIntensity: defaultScanLinesIntensity, Keys.scanLinesVertical: defaultScanLinesVertical, ]) @@ -201,6 +221,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: defaults.double(forKey: Keys.frameRate), errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, + rainFonts: (defaults.array(forKey: Keys.rainFonts) as? [String]) ?? defaultRainFonts, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical) ).sanitized() @@ -223,6 +244,7 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.frameRate, forKey: Keys.frameRate) defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) + defaults.set(options.rainFonts, forKey: Keys.rainFonts) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) defaults.set(options.scanLinesVertical, forKey: Keys.scanLinesVertical) defaults.synchronize() @@ -312,6 +334,12 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") + /// Pull-down listing installed fonts; ticked entries are the selection. + private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) + /// Lists every installed family instead of only rain-suitable ones. + private let showAllFontsCheckbox = NSButton(checkboxWithTitle: "Show all fonts", target: nil, action: nil) + /// Fonts currently ticked in the pull-down, in selection order. + private var selectedRainFonts: [String] = [] private var didClose = false /// Creates native options UI with a host-owned apply action. @@ -326,6 +354,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, defer: false ) super.init() + RainFontCatalog.prefetch() buildWindow() } @@ -353,6 +382,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters + selectedRainFonts = options.rainFonts + rebuildRainFontsMenu() sizeWindowToFitContent() } @@ -448,6 +479,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), makeRainDurationSection(), + makeRainFontsSection(), makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), ])) @@ -457,7 +489,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, // Keep every page the same size so switching tabs never moves the dialog buttons. NSLayoutConstraint.activate([ tabs.widthAnchor.constraint(equalToConstant: 520), - tabs.heightAnchor.constraint(equalToConstant: 460), + tabs.heightAnchor.constraint(equalToConstant: 550), tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), ]) @@ -944,6 +976,80 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds the rain fonts picker: heading, description, "show all" checkbox, then a pull-down of installed fonts. + private func makeRainFontsSection() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let heading = NSTextField(labelWithString: "Fonts") + heading.font = .systemFont(ofSize: 13, weight: .semibold) + + rainFontsButton.setAccessibilityLabel("Fonts") + rainFontsButton.translatesAutoresizingMaskIntoConstraints = false + rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true + (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail + + showAllFontsCheckbox.target = self + showAllFontsCheckbox.action = #selector(showAllFontsToggled(_:)) + + stack.addArrangedSubview(heading) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription)) + stack.addArrangedSubview(showAllFontsCheckbox) + stack.addArrangedSubview(rainFontsButton) + stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) + stack.setCustomSpacing(8, after: showAllFontsCheckbox) + return stack + } + + /// Rebuilds the font menu so it reflects the current selection and "show all" state. + private func rebuildRainFontsMenu() { + let menu = NSMenu() + let summary = selectedRainFonts.isEmpty ? "Default (system font)" : selectedRainFonts.joined(separator: ", ") + menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) + + let defaultItem = NSMenuItem(title: "Default (system font)", action: #selector(clearRainFonts(_:)), keyEquivalent: "") + defaultItem.target = self + defaultItem.state = selectedRainFonts.isEmpty ? .on : .off + menu.addItem(defaultItem) + menu.addItem(.separator()) + + let families = RainFontCatalog.families(showAll: showAllFontsCheckbox.state == .on, alwaysInclude: selectedRainFonts) + for family in families { + let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") + item.target = self + item.representedObject = family + item.state = selectedRainFonts.contains(family) ? .on : .off + menu.addItem(item) + } + rainFontsButton.menu = menu + rainFontsButton.selectItem(at: 0) + } + + /// Adds or removes the chosen family, ignoring additions beyond the maximum. + @objc private func toggleRainFont(_ sender: NSMenuItem) { + guard let family = sender.representedObject as? String else { return } + if let index = selectedRainFonts.firstIndex(of: family) { + selectedRainFonts.remove(at: index) + } else if selectedRainFonts.count < MatrixScreenSaverOptions.maximumRainFonts { + selectedRainFonts.append(family) + } + rebuildRainFontsMenu() + } + + /// Clears the selection so the built-in glyphs and system font are used. + @objc private func clearRainFonts(_ sender: Any?) { + selectedRainFonts = [] + rebuildRainFontsMenu() + } + + /// Refreshes the font list after the "Show all fonts" checkbox changes. + @objc private func showAllFontsToggled(_ sender: Any?) { + rebuildRainFontsMenu() + } + /// Builds a labeled text input row with its description. private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { let stack = NSStackView() @@ -1143,6 +1249,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRate: frameRate, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), + rainFonts: selectedRainFonts, scanLinesIntensity: scanLinesSlider.doubleValue / 100, scanLinesVertical: scanLinesDirectionPopup.indexOfSelectedItem == 0 ).sanitized() diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c6d0b13..2254b41 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -90,6 +90,17 @@ final class MatrixScreenSaverView: ScreenSaverView { private var lineHeight: CGFloat = 22 private var regularFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium) private var boldFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .semibold) + /// Resolved rain fonts by family name for the current cell size. + private var rainFonts: [String: NSFont] = [:] + + /// Resolves each family name to a font at the given size, skipping fonts that are not installed. + private static func makeRainFonts(names: [String], size: CGFloat) -> [String: NSFont] { + var fonts = [String: NSFont]() + for name in names { + if let f = RainFontCatalog.font(named: name, size: size) { fonts[name] = f } + } + return fonts + } private var titleFont = NSFont.systemFont(ofSize: 13, weight: .medium) private var textInsetY: CGFloat = 2 private var colorCache: [TerminalColor: NSColor] = [:] @@ -103,6 +114,8 @@ final class MatrixScreenSaverView: ScreenSaverView { let textInsetY: CGFloat let palette: [TerminalColor] let scalars: [UnicodeScalar] + /// Selected rain font families the rasters were built for. + let rainFonts: [String] } /// Inputs used by the current regular and bold rasters. private var nativeGlyphCacheKey: GlyphCacheKey? @@ -479,6 +492,7 @@ final class MatrixScreenSaverView: ScreenSaverView { let fontSize = fittedTerminalFontSize(forCellWidth: cellWidth, cellHeight: lineHeight) regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium) boldFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .semibold) + rainFonts = Self.makeRainFonts(names: nativeRenderer.activeRainFonts, size: fontSize) textInsetY = max(0, floor((lineHeight - regularFont.pointSize) / 2) - 1) rebuildNativeGlyphCache() @@ -932,10 +946,11 @@ final class MatrixScreenSaverView: ScreenSaverView { let palette = nativeRenderer.levelColors let key = GlyphCacheKey(cellSize: CGSize(width: cellWidth, height: lineHeight), regularFont: regularFont, boldFont: boldFont, textInsetY: textInsetY, - palette: palette, scalars: nativeRenderer.supportedScalars) + palette: palette, scalars: nativeRenderer.supportedScalars, + rainFonts: nativeRenderer.activeRainFonts) guard nativeGlyphCacheKey != key else { return } - nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont) - nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont) + nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont, rainFonts: rainFonts) + nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts) nativeGlyphCacheKey = key } @@ -953,7 +968,8 @@ final class MatrixScreenSaverView: ScreenSaverView { /// Builds per-level glyph images for all supported scalar values. private func makeNativeGlyphCaches( palette: [TerminalColor], - font: NSFont + font: NSFont, + rainFonts: [String: NSFont] ) -> [[UnicodeScalar: CGImage]] { var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count) let scalars = nativeRenderer.supportedScalars @@ -964,7 +980,11 @@ final class MatrixScreenSaverView: ScreenSaverView { ] caches[level].reserveCapacity(scalars.count) for scalar in scalars { - guard let image = makeNativeGlyphImage(for: scalar, attributes: attributes) else { + var glyphAttributes = attributes + if let name = nativeRenderer.rainFontByScalar[scalar], let rf = rainFonts[name] { + glyphAttributes[.font] = rf + } + guard let image = makeNativeGlyphImage(for: scalar, drawing: nativeRenderer.rainSourceByScalar[scalar], attributes: glyphAttributes) else { continue } caches[level][scalar] = image @@ -976,6 +996,7 @@ final class MatrixScreenSaverView: ScreenSaverView { /// Rasterizes a single glyph into a native Core Graphics image. private func makeNativeGlyphImage( for scalar: UnicodeScalar, + drawing: UnicodeScalar? = nil, attributes: [NSAttributedString.Key: Any] ) -> CGImage? { let pixelWidth = max(Int(ceil(cellWidth)), 1) @@ -999,7 +1020,7 @@ final class MatrixScreenSaverView: ScreenSaverView { return glyphContext.makeImage() } - let text = String(scalar) + let text = String(drawing ?? scalar) let textSize = (text as NSString).size(withAttributes: attributes) let point = NSPoint( x: max(0, floor((cellWidth - textSize.width) / 2)), diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 304771f..9264fba 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation final class NativeMatrixRenderer { @@ -13,6 +14,8 @@ final class NativeMatrixRenderer { var frameRate = 25.0 var errorRate = 1.0 var characters = "" + /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs and system font. + var rainFonts: [String] = [] var neoMessageLines: [String] = NeoMessageScene.defaultLines /// When true the 10-second multi-screen sync delay is skipped so scenes /// start within one second of launch. Set for preview contexts where @@ -33,6 +36,7 @@ final class NativeMatrixRenderer { frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, + rainFonts: rainFonts, neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, skipSyncDelay: skipSyncDelay ) @@ -214,13 +218,64 @@ final class NativeMatrixRenderer { glyphs.append(UnicodeScalar(0x2588)!) return glyphs }() + /// Glyph tables derived from the rain fonts the user selected. + struct RainFontTables { + /// Glyph keys per font, picked 50/50 across fonts when more than one is selected. + var pools: [[UnicodeScalar]] = [] + /// Font family each glyph key is drawn with. + var fontByScalar: [UnicodeScalar: String] = [:] + /// Alias scalar -> character actually drawn (only for aliased ASCII glyphs). + var sourceByScalar: [UnicodeScalar: UnicodeScalar] = [:] + /// Upper bound on glyphs taken from one font, sampled evenly, to keep the glyph cache small. + static let maximumGlyphsPerFont = 512 + } + + /// Glyphs a rain font really has. ASCII glyphs are keyed by a private-use alias (U+F000 + code) + /// so they render in the rain font without colliding with the system-font intro/terminal text. + static func glyphEntries(forFont name: String) -> [(key: UnicodeScalar, source: UnicodeScalar)] { + guard let font = RainFontCatalog.font(named: name, size: 16) else { return [] } + let cs = CTFontCopyCharacterSet(font) as CharacterSet + var out = [(key: UnicodeScalar, source: UnicodeScalar)]() + for v in UInt32(0x21)...UInt32(0xFFFF) { + guard let sc = UnicodeScalar(v), cs.contains(sc), + v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588, + !CharacterSet.whitespacesAndNewlines.contains(sc), + !CharacterSet.controlCharacters.contains(sc) else { continue } + if v < 0x80, let alias = UnicodeScalar(0xF000 + v) { + out.append((alias, sc)) + } else { + out.append((sc, sc)) + } + } + let limit = RainFontTables.maximumGlyphsPerFont + guard out.count > limit else { return out } + let step = Double(out.count) / Double(limit) + return (0.. RainFontTables { + var tables = RainFontTables() + var seen = Set() + for name in fonts { + var pool = [UnicodeScalar]() + for entry in glyphEntries(forFont: name) where seen.insert(entry.key).inserted { + pool.append(entry.key) + tables.fontByScalar[entry.key] = name + if entry.key != entry.source { tables.sourceByScalar[entry.key] = entry.source } + } + if !pool.isEmpty { tables.pools.append(pool) } + } + return tables + } + private static let palette = makePalette() /// Returns the glyph pool for the given characters string, deduplicating and falling back to the default pool when empty. - private static func resolveGlyphPool(characters: String) -> [UnicodeScalar] { + private static func resolveGlyphPool(characters: String, fallback: [UnicodeScalar]) -> [UnicodeScalar] { let trimmed = characters.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { - return defaultGlyphPool + return fallback } var seen = Set() var pool = [UnicodeScalar]() @@ -232,7 +287,7 @@ final class NativeMatrixRenderer { pool.append(scalar) } } - return pool.isEmpty ? defaultGlyphPool : pool + return pool.isEmpty ? fallback : pool } /// The content margin (in cells) used for the text row and columns in number/neo scenes. @@ -258,6 +313,16 @@ final class NativeMatrixRenderer { private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool + /// When set, each rain glyph picks a selected font at random, then a glyph within it. + private var activeMixPools: [[UnicodeScalar]]? = nil + /// Glyph tables for the current `activeRainFonts`. + private var rainTables = RainFontTables() + /// Rain font families the glyph tables were built for. + private(set) var activeRainFonts: [String] = [] + /// Font family to draw each rain glyph key with. + private(set) var rainFontByScalar: [UnicodeScalar: String] = [:] + /// Character actually drawn for aliased glyph keys. + private(set) var rainSourceByScalar: [UnicodeScalar: UnicodeScalar] = [:] var supportedScalars: [UnicodeScalar] { let defaultSet = Set(Self.defaultGlyphPool) @@ -459,7 +524,19 @@ final class NativeMatrixRenderer { pendingSimulationSteps = 0.0 } - activeGlyphPool = Self.resolveGlyphPool(characters: sanitizedConfiguration.characters) + if sanitizedConfiguration.rainFonts != activeRainFonts { + activeRainFonts = sanitizedConfiguration.rainFonts + rainTables = Self.makeRainTables(fonts: activeRainFonts) + rainFontByScalar = rainTables.fontByScalar + rainSourceByScalar = rainTables.sourceByScalar + } + let fontPoolUnion = rainTables.pools.flatMap { $0 } + activeGlyphPool = Self.resolveGlyphPool( + characters: sanitizedConfiguration.characters, + fallback: fontPoolUnion.isEmpty ? Self.defaultGlyphPool : fontPoolUnion + ) + let customChars = !sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + activeMixPools = (!customChars && rainTables.pools.count > 1) ? rainTables.pools : nil let shouldResetSceneSequence = columns > 0 && @@ -1114,7 +1191,12 @@ final class NativeMatrixRenderer { /// Returns a random glyph from the supported rain character pool. private func randomGlyph() -> UnicodeScalar { - activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0") + if let pools = activeMixPools, + let pool = pools.randomElement(using: &rainRNG), + let glyph = pool.randomElement(using: &rainRNG) { + return glyph + } + return activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0") } /// Returns a random ASCII numeral for the startup number scene. @@ -1344,3 +1426,41 @@ final class NativeMatrixRenderer { return result } } + +/// Lists installed font families and resolves them for rain rendering. +enum RainFontCatalog { + /// Resolves a family, PostScript or full font name to a font. + static func font(named name: String, size: CGFloat) -> NSFont? { + NSFont(name: name, size: size) + ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size) + } + + /// Families that look useful for rain: katakana-capable fonts and anything named "matrix". + private static let suitableFamilies: Set = { + var result = Set() + for family in NSFontManager.shared.availableFontFamilies { + if family.lowercased().contains("matrix") { result.insert(family); continue } + guard let font = font(named: family, size: 16) else { continue } + let cs = CTFontCopyCharacterSet(font) as CharacterSet + var count = 0 + for v in Array(0x30A0...0x30FF) + Array(0xFF61...0xFF9F) { + if let sc = UnicodeScalar(UInt32(v)), cs.contains(sc) { count += 1 } + } + if count >= 20 { result.insert(family) } + } + return result + }() + + /// Scans the installed fonts on a background queue so the first open of the font list does not stall the main thread. + static func prefetch() { + DispatchQueue.global(qos: .utility).async { _ = suitableFamilies } + } + + /// Sorted font families, optionally limited to rain-suitable ones (selected fonts always stay listed). + static func families(showAll: Bool, alwaysInclude: [String]) -> [String] { + let all = NSFontManager.shared.availableFontFamilies + var names = showAll ? Set(all) : suitableFamilies + names.formUnion(alwaysInclude) + return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } +} From e624a73191879dbb2cf0f0c5618b014eb42b1d01 Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 21:58:47 +1100 Subject: [PATCH 02/17] fix: size options pages up front to avoid unsatisfiable layout constraints - create each options page at its real size instead of zero --- Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 94ace21..1706929 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1173,7 +1173,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private func makeOptionsTab(title: String, views: [NSView]) -> NSTabViewItem { let item = NSTabViewItem(identifier: title) item.label = title - let contentView = NSView() + // 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 contentView = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 550)) item.view = contentView let stack = NSStackView(views: views) stack.orientation = .vertical From e33e11143d5c191354c863183d30413bdb8818ad Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 22:17:27 +1100 Subject: [PATCH 03/17] docs: credit and link the Matrix fonts - document the Fonts option in the options table - add a Matrix fonts section linking Matrix Code and Matrix Resurrected from Rezmason/matrix --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bca6ea9..725d707 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,19 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | Frame rate | Set the frame rate per second. A positive number less than or equal to 1000. | 25 | | Error rate | Set the factor for the rate of character changes. A non-negative number. | 1.0 | | Characters | Restrict rain glyphs to a custom set (e.g. ATGC). Leave empty for the default. | *(empty)* | +| Fonts | Pick installed fonts for the rain glyphs (up to 8, mixed evenly). Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | | Neo message scene | Show the Neo message intro before the main scene. | On | | Neo message speed | Typing and pause speed multiplier for the Neo message intro. A positive number. | 1.0 | | Neo message lines | Lines typed during the Neo message intro. Between 1 and 10 lines, up to 256 characters each. | The 4 classic lines | | Scan lines intensity | Opacity of the CRT scan line overlay (0 = off, 100 = fully opaque). | 25 | | Scan lines vertical | Orientation of the scan line overlay (vertical or horizontal). | On (vertical) | +### Matrix fonts + +The **Fonts** option lists installed fonts that look suited to rain, so the glyphs can match the film. Tick **Show all fonts** to list every family. No fonts are bundled, so install them yourself first. + +The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. + ### Wallpaper menubar menu ![menubar.jpg](docs/wallpaper-menubar.jpg) @@ -57,7 +64,7 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr - Native Swift/AppKit screen saver bundle - Edge-to-edge full-screen saver output, with terminal-style chrome kept in the preview host - In-process renderer for `rain-forever` with an optional upstream `number` intro -- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Neo message scene/speed/lines, and Scan lines +- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Fonts, Neo message scene/speed/lines, and Scan lines - Preview app for fast iteration without reinstalling ### Repository layout From aa3b03bd4d9de38ecff7e879c7ea4a8cf23db7ef Mon Sep 17 00:00:00 2001 From: Troy Fisher Date: Wed, 28 Oct 2026 23:07:23 +1100 Subject: [PATCH 04/17] test: cover rain font selection - test rain font glyph tables, aliasing and catalog listing against Menlo - test rain font option sanitizing, persistence and renderer hand-off - allow the taller options sheet that now holds the Fonts section --- .../NativeMatrixRendererTests.swift | 46 +++++++++++++++++++ .../SharedSaverTests.swift | 17 ++++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index 5940e5a..3d84f63 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -1,6 +1,9 @@ +import AppKit import Foundation func nativeMatrixRendererTests() { + rainFontTests() + ok(NativeMatrixRenderer().seedOffset == 0, "NativeMatrixRenderer: seedOffset defaults to 0") @@ -123,3 +126,46 @@ func nativeMatrixRendererTests() { "NativeMatrixRenderer: pause between display phase advances preserves identical Number-scene timestamps") ok(earlyText == startupText, "NativeMatrixRenderer: a display starting after resume keeps the same scripted content and seed") } + +/// Covers the rain font tables and catalog using Menlo, which ships with every macOS install. +private func rainFontTests() { + let tables = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"]) + // Menlo has more glyphs than the per-font limit, so pick an alias that survived the sampling. + let aliasPool = (tables.pools.first ?? []).filter { (0xF021...0xF07E).contains($0.value) } + let alias = aliasPool.first ?? UnicodeScalar(0xF041)! + ok(tables.pools.count == 1 && !(tables.pools.first ?? []).isEmpty, + "RainFont: an installed font yields one non-empty glyph pool") + ok((tables.pools.first ?? []).count <= NativeMatrixRenderer.RainFontTables.maximumGlyphsPerFont, + "RainFont: a pool never exceeds the per-font glyph limit") + ok(!aliasPool.isEmpty && tables.sourceByScalar[alias]?.value == alias.value - 0xF000 && tables.fontByScalar[alias] == "Menlo", + "RainFont: ASCII glyphs are keyed by a private-use alias that draws the real character in the rain font") + ok(!(tables.pools.first ?? []).contains { $0.value < 0x80 }, + "RainFont: plain ASCII scalars stay out of the pool so intro text keeps the system font") + + ok(NativeMatrixRenderer.makeRainTables(fonts: ["No Such Font 9F3A"]).pools.isEmpty, + "RainFont: fonts that are not installed are skipped") + ok(NativeMatrixRenderer.makeRainTables(fonts: ["Menlo", "Menlo"]).pools.count == 1, + "RainFont: a duplicate font adds no second pool") + + ok(RainFontCatalog.font(named: "Menlo", size: 12) != nil && RainFontCatalog.font(named: "No Such Font 9F3A", size: 12) == nil, + "RainFontCatalog: resolves installed fonts and rejects missing ones") + let everything = RainFontCatalog.families(showAll: true, alwaysInclude: []) + ok(everything.contains("Menlo") && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, + "RainFontCatalog: showing all fonts lists installed families in sorted order") + ok(RainFontCatalog.families(showAll: false, alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), + "RainFontCatalog: a selected font stays listed even when it is filtered out") + + let renderer = NativeMatrixRenderer() + var config = NativeMatrixRenderer.Configuration() + config.rainFonts = ["Menlo"] + ok(config.sanitized().rainFonts == ["Menlo"], "RainFont: configuration sanitizing keeps the selected fonts") + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts == ["Menlo"] && !renderer.rainSourceByScalar.isEmpty && renderer.rainFontByScalar.values.allSatisfy { $0 == "Menlo" }, + "RainFont: the renderer builds glyph tables for the selected fonts") + ok(renderer.rainSourceByScalar.keys.allSatisfy { renderer.supportedScalars.contains($0) }, + "RainFont: the renderer supports the aliased rain glyphs") + config.rainFonts = [] + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts.isEmpty && renderer.rainFontByScalar.isEmpty, + "RainFont: clearing the selection drops the font glyph tables") +} diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 46ef4c6..c62d971 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -90,6 +90,20 @@ func sharedSaverTests() { options.save(to: defaults) check(MatrixScreenSaverOptions.load(from: defaults).frameRate > 0, "shared persistence retains option sanitization") + var fontOptions = MatrixScreenSaverOptions() + check(fontOptions.rainFonts.isEmpty, "rain fonts default to none, keeping the built-in glyphs") + fontOptions.rainFonts = [" Matrix-Code ", "Matrix-Code", "", "Matrix-Resurrected"] + (1...10).map { "Font \($0)" } + let cleanFonts = fontOptions.sanitized().rainFonts + check(cleanFonts.prefix(2) == ["Matrix-Code", "Matrix-Resurrected"] && Set(cleanFonts).count == cleanFonts.count, + "rain font sanitizing trims names and drops blanks and duplicates") + check(cleanFonts.count == MatrixScreenSaverOptions.maximumRainFonts, "rain font sanitizing caps the selection") + fontOptions.rainFonts = ["Matrix-Code", "Matrix-Resurrected"] + fontOptions.save(to: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], + "rain fonts persist through save and load") + check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], + "rain fonts reach the renderer configuration") + let original = MatrixScreenSaverOptions() var live = original live.twinkleEnabled.toggle() @@ -164,7 +178,8 @@ private func checkOptionsLayout(in window: NSWindow) { let content = window.contentView! content.layoutSubtreeIfNeeded() let size = content.bounds.size - check(size.width <= 600 && size.height <= 600, + // 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") guard let tabs = descendants(of: content).compactMap({ $0 as? NSTabView }).first else { check(false, "options organize settings into native tabs") From 509ab588b2ff4802cadccf411ac71a081bf94411 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 17:22:53 +0100 Subject: [PATCH 05/17] fix: improve rain font selection and fallback - discover hidden fonts and remove font filtering - retain missing glyphs for cached macOS fallback - share rain rasters across weight caches - add font reset and a dynamic family-only default label - simplify help text and cover font behavior with tests --- README.md | 2 +- .../MatrixScreenSaverOptions.swift | 42 ++++---- .../MatrixScreenSaverView.swift | 23 +++- .../NativeMatrixRenderer.swift | 100 +++++++++++++----- .../NativeMatrixRendererTests.swift | 69 +++++++++++- .../SharedSaverTests.swift | 58 ++++++++++ 6 files changed, 239 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index a7949c7..ef725d9 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ cancels its command. The sandboxed screen saver may not have the same access as ### Matrix fonts -The **Fonts** option lists installed fonts that look suited to rain, so the glyphs can match the film. Tick **Show all fonts** to list every family. No fonts are bundled, so install them yourself first. +The **Fonts** option lists installed font families, including hidden fonts discovered in the standard system and user font folders. Mix up to 8 fonts. Default and custom characters missing from a selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. No fonts are bundled, so install them yourself first. The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index ff1368f..c78f4e5 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1,4 +1,5 @@ import AppKit +import CoreText import ScreenSaver struct MatrixScreenSaverOptions: Equatable { @@ -91,13 +92,12 @@ struct MatrixScreenSaverOptions: Equatable { ] static let defaultCharacterSizeIndex = 8 static let rainDensityDescription = "Rain density multiplier. Default: 1.0." - static let rainRunForeverDescription = "When enabled (default), rain continuously spawns new falling lines. When disabled, rain stops starting new lines after the specified duration; lines already falling finish and fade out, then the full scene sequence restarts." static let rainDurationSecondsDescription = "How long rain keeps starting new falling lines (1–9999 seconds) before it winds down. Default: 300 (5 minutes)." static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." static let errorRateDescription = "Character change rate factor. Default: 1.0." static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default." /// Help text shown under the Fonts picker. - static let rainFontsDescription = "Fonts for the falling glyphs. Pick several to mix them. None keeps the built-in glyphs. Intro text always uses the system font." + static let rainFontsDescription = "Mix up to 8 fonts." /// Default selection: no fonts, which keeps the built-in glyphs and system font. static let defaultRainFonts: [String] = [] /// Maximum number of rain fonts that can be mixed. @@ -441,8 +441,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let charactersField = NSTextField(string: "") /// Pull-down listing installed fonts; ticked entries are the selection. private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) - /// Lists every installed family instead of only rain-suitable ones. - private let showAllFontsCheckbox = NSButton(checkboxWithTitle: "Show all fonts", target: nil, action: nil) /// Fonts currently ticked in the pull-down, in selection order. private var selectedRainFonts: [String] = [] private var didClose = false @@ -1234,7 +1232,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } - /// Builds the rain fonts picker: heading, description, "show all" checkbox, then a pull-down of installed fonts. + /// Builds the rain fonts picker with every discovered font family. private func makeRainFontsSection() -> NSView { let stack = NSStackView() stack.orientation = .vertical @@ -1250,31 +1248,41 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail - showAllFontsCheckbox.target = self - showAllFontsCheckbox.action = #selector(showAllFontsToggled(_:)) + let resetButton = NSButton(title: "Reset", target: self, action: #selector(clearRainFonts(_:))) + resetButton.bezelStyle = .rounded + resetButton.controlSize = .small + resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + resetButton.setAccessibilityLabel("Reset fonts to system default") + resetButton.toolTip = "Use the default system font" + let row = NSStackView(views: [rainFontsButton, resetButton]) + row.orientation = .horizontal + row.alignment = .centerY + row.spacing = 8 stack.addArrangedSubview(heading) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription)) - stack.addArrangedSubview(showAllFontsCheckbox) - stack.addArrangedSubview(rainFontsButton) + stack.addArrangedSubview(row) stack.setCustomSpacing(8, after: stack.arrangedSubviews[1]) - stack.setCustomSpacing(8, after: showAllFontsCheckbox) return stack } - /// Rebuilds the font menu so it reflects the current selection and "show all" state. + /// Rebuilds the full font menu with the current selection. private func rebuildRainFontsMenu() { let menu = NSMenu() - let summary = selectedRainFonts.isEmpty ? "Default (system font)" : selectedRainFonts.joined(separator: ", ") + let systemFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium) + // Resolve the backing family instead of exposing the system alias or face weights. + let backingFont = CTFontCreateWithGraphicsFont(CTFontCopyGraphicsFont(systemFont, nil), 16, nil, nil) + let defaultTitle = "Default - \(CTFontCopyFamilyName(backingFont))" + let summary = selectedRainFonts.isEmpty ? defaultTitle : selectedRainFonts.joined(separator: ", ") menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) - let defaultItem = NSMenuItem(title: "Default (system font)", action: #selector(clearRainFonts(_:)), keyEquivalent: "") + let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "") defaultItem.target = self defaultItem.state = selectedRainFonts.isEmpty ? .on : .off menu.addItem(defaultItem) menu.addItem(.separator()) - let families = RainFontCatalog.families(showAll: showAllFontsCheckbox.state == .on, alwaysInclude: selectedRainFonts) + let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) for family in families { let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") item.target = self @@ -1303,11 +1311,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rebuildRainFontsMenu() } - /// Refreshes the font list after the "Show all fonts" checkbox changes. - @objc private func showAllFontsToggled(_ sender: Any?) { - rebuildRainFontsMenu() - } - /// Builds a labeled text input row with its description. private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView { let stack = NSStackView() @@ -1376,7 +1379,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.addArrangedSubview(checkboxRow) stack.addArrangedSubview(secondsRow) - stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription)) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) return stack } diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index ebf8014..c938184 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -968,7 +968,8 @@ final class MatrixScreenSaverView: ScreenSaverView { guard nativeGlyphCacheKey != key else { return } nativeSupplementalGlyphs.removeAll(keepingCapacity: true) nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont, rainFonts: rainFonts) - nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts) + nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts, + sharingRainGlyphs: nativeRegularGlyphsByLevel) nativeGlyphCacheKey = key } @@ -984,10 +985,18 @@ final class MatrixScreenSaverView: ScreenSaverView { } /// Builds per-level glyph images for all supported scalar values. + /// + /// - Parameters: + /// - palette: Colors for the cache levels. + /// - font: System font weight used for terminal text. + /// - rainFonts: Selected faces used regardless of terminal text weight. + /// - sharingRainGlyphs: Caches from the same palette and layout whose selected-font images can be reused. + /// - Returns: Glyph images indexed by palette level and renderer scalar. private func makeNativeGlyphCaches( palette: [TerminalColor], font: NSFont, - rainFonts: [String: NSFont] + rainFonts: [String: NSFont], + sharingRainGlyphs: [[UnicodeScalar: CGImage]] = [] ) -> [[UnicodeScalar: CGImage]] { var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count) let scalars = nativeRenderer.supportedScalars @@ -1000,6 +1009,10 @@ final class MatrixScreenSaverView: ScreenSaverView { for scalar in scalars { var glyphAttributes = attributes if let name = nativeRenderer.rainFontByScalar[scalar], let rf = rainFonts[name] { + if sharingRainGlyphs.indices.contains(level), let image = sharingRainGlyphs[level][scalar] { + caches[level][scalar] = image + continue + } glyphAttributes[.font] = rf } guard let image = makeNativeGlyphImage(for: scalar, drawing: nativeRenderer.rainSourceByScalar[scalar], attributes: glyphAttributes) else { @@ -1012,6 +1025,12 @@ final class MatrixScreenSaverView: ScreenSaverView { } /// Rasterizes a single glyph into a native Core Graphics image. + /// + /// - Parameters: + /// - scalar: Renderer cache key, which may be a private-use rain alias. + /// - drawing: Selected-font source character, enabling fitted rendering; nil keeps terminal-text rendering. + /// - attributes: Font and foreground color used to draw the glyph. + /// - Returns: The cell-sized raster, or nil if a bitmap context could not be created. private func makeNativeGlyphImage( for scalar: UnicodeScalar, drawing: UnicodeScalar? = nil, diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 65b2e6b..2babb18 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -233,9 +233,12 @@ final class NativeMatrixRenderer { private static let maxSimulationStepsPerTick = 2 private static let disableBoldFlag: UInt32 = 0x1 private static let blankRenderCell = RenderCell() + /// Half-width katakana characters used by the built-in rain. + private static let defaultKatakanaGlyphs = (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) } + private static let defaultGlyphPool: [UnicodeScalar] = { var glyphs = Array("0123456789".unicodeScalars) - glyphs.append(contentsOf: (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) }) + glyphs.append(contentsOf: defaultKatakanaGlyphs) glyphs.append(contentsOf: "<>*+.:=_|".unicodeScalars) // Printable ASCII for terminal text lines glyphs.append(contentsOf: (0x20..<0x7F).compactMap { UnicodeScalar($0) }) @@ -255,20 +258,21 @@ final class NativeMatrixRenderer { static let maximumGlyphsPerFont = 512 } - /// Samples visible glyphs supported by a font, optionally restricted to the custom character set. + /// Samples a font's repertoire while retaining default or custom characters for macOS fallback. /// - Parameters: /// - name: Installed font family or font name. /// - characters: Custom rain characters; empty uses the font's BMP repertoire. - /// - Returns: At most `maximumGlyphsPerFont` supported characters. + /// - Returns: At most `maximumGlyphsPerFont` characters; missing glyphs are resolved at rasterization. static func glyphEntries(forFont name: String, characters: String = "") -> [UnicodeScalar] { guard let font = RainFontCatalog.font(named: name, size: 16) else { return [] } let cs = CTFontCopyCharacterSet(font) as CharacterSet + let defaults = Set(defaultGlyphPool) var out = [UnicodeScalar]() let values = characters.isEmpty ? Array(UInt32(0x21)...UInt32(0xFFFF)) : Array(Set(characters.unicodeScalars.map(\.value))).sorted() for v in values { - guard let sc = UnicodeScalar(v), cs.contains(sc), + guard let sc = UnicodeScalar(v), cs.contains(sc) || !characters.isEmpty || defaults.contains(sc), v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588, !CharacterSet.whitespacesAndNewlines.contains(sc), !CharacterSet.controlCharacters.contains(sc) else { continue } @@ -276,8 +280,13 @@ final class NativeMatrixRenderer { } let limit = RainFontTables.maximumGlyphsPerFont guard out.count > limit else { return out } - let step = Double(out.count) / Double(limit) - return (0.. Double { + let traits = descriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any] + let weight = (traits?[.weight] as? NSNumber)?.doubleValue ?? 0 + return abs(weight) + (descriptor.symbolicTraits.contains(.italic) ? 10 : 0) + } + /// Resolves a family, PostScript or full font name to a font. static func font(named name: String, size: CGFloat) -> NSFont? { - NSFont(name: name, size: size) + if fontFileInventory == nil { refreshHiddenFonts() } + if let descriptor = hiddenFonts[name] { + // A descriptor retains the file URL; resolving a hidden name alone may substitute Times. + return NSFont(descriptor: descriptor, size: size) + } + return NSFont(name: name, size: size) ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size) } - /// Families that look useful for rain: katakana-capable fonts and anything named "matrix". - private static let suitableFamilies: Set = { - var result = Set() - for family in NSFontManager.shared.availableFontFamilies { - if family.lowercased().contains("matrix") { result.insert(family); continue } - guard let font = font(named: family, size: 16) else { continue } - let cs = CTFontCopyCharacterSet(font) as CharacterSet - var count = 0 - for v in Array(0x30A0...0x30FF) + Array(0xFF61...0xFF9F) { - if let sc = UnicodeScalar(UInt32(v)), cs.contains(sc) { count += 1 } - } - if count >= 20 { result.insert(family) } - } - return result - }() - - /// Sorted font families, optionally limited to rain-suitable ones (selected fonts always stay listed). - static func families(showAll: Bool, alwaysInclude: [String]) -> [String] { - let all = NSFontManager.shared.availableFontFamilies - var names = showAll ? Set(all) : suitableFamilies + /// Lists installed font families, retaining selected names for deselection. + /// - Parameter alwaysInclude: Selected families to keep available for deselection. + /// - Returns: Unique family names in localized alphabetical order. + static func families(alwaysInclude: [String]) -> [String] { + refreshHiddenFonts() + let all = Set(NSFontManager.shared.availableFontFamilies).union(hiddenFonts.keys) + var names = all names.formUnion(alwaysInclude) return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index c277e51..ab55a72 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -160,11 +160,37 @@ private func rainFontTests() { ok(RainFontCatalog.font(named: "Menlo", size: 12) != nil && RainFontCatalog.font(named: "No Such Font 9F3A", size: 12) == nil, "RainFontCatalog: resolves installed fonts and rejects missing ones") - let everything = RainFontCatalog.families(showAll: true, alwaysInclude: []) - ok(everything.contains("Menlo") && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, - "RainFontCatalog: showing all fonts lists installed families in sorted order") - ok(RainFontCatalog.families(showAll: false, alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), - "RainFontCatalog: a selected font stays listed even when it is filtered out") + let everything = RainFontCatalog.families(alwaysInclude: []) + ok(Set(NSFontManager.shared.availableFontFamilies).isSubset(of: Set(everything)) && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }, + "RainFontCatalog: always lists every installed family in sorted order") + let systemFiles = FileManager.default.enumerator(at: URL(fileURLWithPath: "/System/Library/Fonts"), includingPropertiesForKeys: nil)! + var checkedHiddenFont = false + for case let url as URL in systemFiles { + guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { continue } + for descriptor in descriptors { + guard let family = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String, + !NSFontManager.shared.availableFontFamilies.contains(family), + let resolved = RainFontCatalog.font(named: family, size: 16) else { continue } + let actualFamily = CTFontCopyFamilyName(resolved) as String + ok(everything.contains(family) && actualFamily == family, + "RainFontCatalog: automatically discovers hidden file-backed families and resolves their actual font") + checkedHiddenFont = true + break + } + if checkedHiddenFont { break } + } + ok(checkedHiddenFont, "RainFontCatalog: exercises an installed hidden system font") + ok(RainFontCatalog.families(alwaysInclude: ["Chosen Font"]).contains("Chosen Font"), + "RainFontCatalog: a missing selected font stays available for deselection") + let defaultKatakana = Set((UInt32(0xFF70)...UInt32(0xFF9D)).compactMap(UnicodeScalar.init)) + let menlo = RainFontCatalog.font(named: "Menlo", size: 16)! + let menloCoverage = CTFontCopyCharacterSet(menlo) as CharacterSet + ok(!defaultKatakana.allSatisfy { menloCoverage.contains($0) } && + defaultKatakana.isSubset(of: Set(tables.sourceByScalar.values)), + "RainFont: default katakana missing from the selected font stay in the pool for fallback") + let customFallback = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"], characters: "Aア") + ok(Set(customFallback.sourceByScalar.values) == Set("Aア".unicodeScalars), + "RainFont: custom characters missing from the selected font stay in the pool for fallback") let renderer = NativeMatrixRenderer() var config = NativeMatrixRenderer.Configuration() @@ -187,6 +213,39 @@ private func rainFontTests() { renderer.updateConfiguration(config) ok(renderer.activeRainFonts.isEmpty && renderer.rainFontByScalar.isEmpty, "RainFont: clearing the selection drops the font glyph tables") + + config.rainFonts = ["Menlo", "Hiragino Sans"] + config.characters = "A" + String(String.UnicodeScalarView((0x30A1...0x30FA).compactMap { UnicodeScalar($0) })) + config.neoMessageSceneEnabled = false + config.numberSceneEnabled = false + config.cmdSceneEnabled = false + config.twinkleEnabled = false + let mixedFallback = NativeMatrixRenderer.makeRainTables(fonts: config.rainFonts, characters: config.characters) + ok(mixedFallback.pools.count == 2 && mixedFallback.pools.allSatisfy { pool in + Set(pool.compactMap { mixedFallback.sourceByScalar[$0] }) == Set(config.characters.unicodeScalars) + }, "RainFont: every selected font retains the full custom set regardless of native coverage") + renderer.updateConfiguration(config) + renderer.resize(to: TerminalSize(columns: 80, rows: 24)) + renderer.overrideSceneStartTime = 1_000 + renderer.start() + defer { renderer.stop() } + var counts: [String: Int] = [:] + for _ in 0..<120 { + renderer.shiftTimeline(by: -0.1) + renderer.advance() + for row in 0..<24 { + for column in 0..<80 { + let cell = renderer[row, column] + if cell.foregroundLevel > 0, let font = renderer.rainFontByScalar[cell.scalar] { + counts[font, default: 0] += 1 + } + } + } + } + let total = counts.values.reduce(0, +) + let menloShare = Double(counts["Menlo", default: 0]) / Double(max(total, 1)) + ok(total > 1_000 && (0.35...0.65).contains(menloShare), + "RainFont: rendered rain mixes selected fonts evenly with fallback-enabled glyph pools") } /// Checks idle intro frames and cursor-only updates through the host's dirty-row contract. diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index a0cea24..34da38b 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -4,6 +4,7 @@ import ScreenSaver /// Exercises the shared view and options controller through their hosting boundaries. func sharedSaverTests() { glyphCacheTests() + rainFontResetTests() firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), @@ -243,6 +244,32 @@ private func firstSceneSynchronizationTests() { } } +/// Checks that the font reset updates the menu and accepted options without resetting other settings. +private func rainFontResetTests() { + var applied: MatrixScreenSaverOptions? + let controller = MatrixScreenSaverOptionsSheetController { applied = $0 } + var options = MatrixScreenSaverOptions() + options.rainFonts = ["Menlo", "Hiragino Sans"] + options.characters = "ATGC" + options.frameRate = 42 + controller.prepare(using: options) + let window = controller.configureSheet() + let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first! + tabs.selectTabViewItem(tabs.tabViewItems.first { $0.label == "Rain" }!) + window.contentView!.layoutSubtreeIfNeeded() + let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) + let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } + check(reset?.controlSize == .small && picker?.title.contains("Menlo") == true, + "font picker exposes a small reset button beside a custom selection") + reset?.performClick(nil) + check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && + picker?.menu?.items.dropFirst(3).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) + check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42, + "font reset applies the default font without changing other options") +} + /// Checks raster reuse through layout and option updates, retaining images to compare identity safely. private func glyphCacheTests() { let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)! @@ -262,6 +289,8 @@ private func glyphCacheTests() { options.frameRate = 42 view.applyWallpaperOptions(options) 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.characters = "☃" view.applyWallpaperOptions(options) check(glyph("☃") != nil && glyph("☃", bold: true) != nil, @@ -287,10 +316,39 @@ private func glyphCacheTests() { view.applyWallpaperOptions(options) let rainKey = UnicodeScalar(0xF0000)! let rainA = glyph(rainKey) + let regularLevels: [[UnicodeScalar: CGImage]] = stored("nativeRegularGlyphsByLevel", in: view) ?? [] + let boldLevels: [[UnicodeScalar: CGImage]] = stored("nativeBoldGlyphsByLevel", in: view) ?? [] + check(!regularLevels.isEmpty && regularLevels.count == boldLevels.count && + zip(regularLevels, boldLevels).allSatisfy { regular, bold in + regular[rainKey] != nil && regular[rainKey] === bold[rainKey] && + regular["A"] != nil && bold["A"] != nil && regular["A"] !== bold["A"] + }, "selected-font rasters share every weight-cache level while system bold stays separate") options.characters = "B" view.applyWallpaperOptions(options) check(rainA != nil && glyph(rainKey) != nil && rainA !== glyph(rainKey), "editing custom rain characters refreshes rasters even when alias keys stay unchanged") + options.characters = "ア" + view.applyWallpaperOptions(options) + let fallbackImage = glyph(rainKey) + let selectedFonts: [String: NSFont] = stored("rainFonts", in: view) ?? [:] + let selectedFont = selectedFonts["Menlo"]! + let line = CTLineCreateWithAttributedString(NSAttributedString(string: "ア", attributes: [.font: selectedFont])) + let runs = CTLineGetGlyphRuns(line) as! [CTRun] + let usesFallback = runs.contains { run in + let font = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont + var glyphID: CGGlyph = 0 + CTRunGetGlyphs(run, CFRange(location: 0, length: 1), &glyphID) + return CTFontCopyFamilyName(font) != CTFontCopyFamilyName(selectedFont) && glyphID != 0 + } + let hasFallbackInk = fallbackImage.map { image in + let bitmap = NSBitmapImageRep(cgImage: image) + return (0.. 0 } + } + } ?? false + check(usesFallback && hasFallbackInk, "missing selected-font katakana resolves to a real fallback glyph and a visible cached raster") + view.applyWallpaperOptions(options) + check(fallbackImage != nil && fallbackImage === glyph(rainKey), "unchanged options reuse the fallback raster without resolving fonts again") options.rainFonts = ["Hiragino Sans"] options.characters = "カ" options.characterWidth = 16 From 32d5dc3a9d8551575dd25349ea2d83ca741df1bf Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:23:40 +0100 Subject: [PATCH 06/17] feat: add Matrix glyph presets and live preview - bundle original Classic and Resurrections fonts with attribution - persist glyph presets and preserve custom settings when switching - limit the font picker to one selection - preview glyphs in green on black with a smaller wrapping font - cover preset rendering, persistence, selection and layout --- README.md | 8 +- Resources/Fonts/LICENSE.txt | 21 +++ Resources/Fonts/Matrix-Code.ttf | Bin 0 -> 7896 bytes Resources/Fonts/Matrix-Resurrected.ttf | Bin 0 -> 20332 bytes Resources/Fonts/README.md | 116 ++++++++++++ .../MatrixScreenSaverOptions.swift | 167 ++++++++++++++---- .../NativeMatrixRenderer.swift | 84 ++++++++- .../NativeMatrixRendererTests.swift | 29 +++ .../SharedSaverTests.swift | 141 +++++++++++++-- build-wallpaper.sh | 2 + build.sh | 1 + tests.sh | 8 +- wallpaper-tests.sh | 11 +- 13 files changed, 527 insertions(+), 61 deletions(-) create mode 100644 Resources/Fonts/LICENSE.txt create mode 100644 Resources/Fonts/Matrix-Code.ttf create mode 100644 Resources/Fonts/Matrix-Resurrected.ttf create mode 100644 Resources/Fonts/README.md diff --git a/README.md b/README.md index ef725d9..75040ad 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | Frame rate | Set the frame rate per second. A positive number less than or equal to 1000. | 25 | | Error rate | Set the factor for the rate of character changes. A non-negative number. | 1.0 | | Characters | Restrict rain glyphs to a custom set (e.g. ATGC). Leave empty for the default. | *(empty)* | -| Fonts | Pick installed fonts for the rain glyphs (up to 8, mixed evenly). Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | +| Font | Pick one installed font for the rain glyphs. Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* | | Neo message scene | Show the Neo message intro before the main scene. | On | | Typing speed | Typing speed multiplier for Neo messages, custom command entry and the trace program. A positive number; pauses keep their natural duration. Output is not typed. | 1.0 | | Name | Name used in the intro message ("Wake up, {name}..."). Only applies when Neo message lines is empty. | *(empty — uses "Neo")* | @@ -59,9 +59,11 @@ cancels its command. The sandboxed screen saver may not have the same access as ### Matrix fonts -The **Fonts** option lists installed font families, including hidden fonts discovered in the standard system and user font folders. Mix up to 8 fonts. Default and custom characters missing from a selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. No fonts are bundled, so install them yourself first. +The **Glyph set** radio buttons in **Rain** select **Default**, **Classic**, or **Resurrections**. Classic and Resurrections use their original bundled fonts and glyph artwork. Default keeps the existing configurable rain. Switching presets preserves your custom font and character settings; these controls are editable when Default is selected. -The free [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. +The **Font** option selects one installed font family, including hidden fonts discovered in the standard system and user font folders. Choosing another font replaces the selection; Reset restores the system font. Existing multi-font settings retain only the first font. Default and custom characters missing from the selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. Install any additional fonts yourself. + +The bundled [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. See [font provenance](Resources/Fonts/README.md) and the [included license](Resources/Fonts/LICENSE.txt). ### Wallpaper menubar menu diff --git a/Resources/Fonts/LICENSE.txt b/Resources/Fonts/LICENSE.txt new file mode 100644 index 0000000..8ed7dd3 --- /dev/null +++ b/Resources/Fonts/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Rezmason + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Resources/Fonts/Matrix-Code.ttf b/Resources/Fonts/Matrix-Code.ttf new file mode 100644 index 0000000000000000000000000000000000000000..95a3a5eec11b24e1ab6278c2b647b53f128765da GIT binary patch literal 7896 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 UBc3tt0~dn#?$apw*T3uj7ico~Q2+n{ literal 0 HcmV?d00001 diff --git a/Resources/Fonts/Matrix-Resurrected.ttf b/Resources/Fonts/Matrix-Resurrected.ttf new file mode 100644 index 0000000000000000000000000000000000000000..58dad207d15c17cb34fe8764a824804ff667350a GIT binary patch literal 20332 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 literal 0 HcmV?d00001 diff --git a/Resources/Fonts/README.md b/Resources/Fonts/README.md new file mode 100644 index 0000000..d169ca0 --- /dev/null +++ b/Resources/Fonts/README.md @@ -0,0 +1,116 @@ +# Rezmason Matrix fonts + +Original, unmodified TrueType fonts from [Rezmason/matrix](https://github.com/Rezmason/matrix), +pinned to commit `5ba90490453ceceb6812d6b1bc658a99a92411d0`. +Copyright (c) 2018 Rezmason. The upstream MIT license is reproduced verbatim in +`LICENSE.txt`; distribute it with the fonts. + +Upstream paths at that commit: + +- `assets/Matrix-Code.ttf` → `Matrix-Code.ttf` +- `assets/Matrix-Resurrected.ttf` → `Matrix-Resurrected.ttf` +- `LICENSE` → `LICENSE.txt` + +Download base: + +SHA-256 checksums: + +```text +5dff3023c91291d28dffc32fd74f5edbbca1969f25077d0499b1f000cabdec5b Matrix-Code.ttf +cad5cb197237f3eeed8d5d251df9c058caeb8179dc97494bb19447d2cf3ca4fc Matrix-Resurrected.ttf +65333257cebb87e8af86201a65f0e550d65b3cf5a468bfee2a69c9d2b5d21123 LICENSE.txt +``` + +## Verified character mappings + +These mappings were inspected directly from the TTF `cmap`, `name`, and `glyf` +tables with FontTools. All Unicode cmap subtables agree. Family, full, and +PostScript names are respectively `Matrix-Code` and `Matrix-Resurrected`; +both use the `Regular` subfamily. + +**Classic TTF repertoire:** 57 nonblank mapped glyphs. Their Unicode code points (hexadecimal) +are the following explicit set, not a contiguous range: + +```text +0022 002A 002B 0030 0031 0032 0033 0034 0035 0037 0038 0039 +003A 003C 003E 007A 007C 00A6 00A9 254C 25AA 30A2 30A6 30A8 +30AA 30AB 30AD 30B1 30B3 30B5 30B7 30B9 30BB 30BD 30BF 30C4 +30C6 30CA 30CB 30CC 30CD 30CF 30D2 30DB 30DE 30DF 30E0 30E1 +30E2 30E4 30E8 30E9 30EA 30EF 30FC A78A E937 +``` + +**Resurrections:** the TTF contains **138**, not 135, nonblank mapped glyphs: +the inclusive private-use range **U+E900–U+E989**. Every character maps to a +different glyph ID with nonzero-width and nonzero-height outline bounds. +Upstream `js/config.js` specifies 135 active slots for its separate MSDF texture. +Direct atlas comparison confirms those slots match **U+E900–U+E986**, in order. +U+E987, U+E988, and U+E989 contain artwork matching inactive atlas slots +153, 154, and 155; exclude them when reproducing the website's active set. +Do not infer the TTF repertoire or atlas ordering from upstream's 57-entry +`glyph order.txt`. + +Both fonts additionally map U+0000, U+0001, and U+0020 to separate glyph IDs +with identical degenerate outlines: three points at (0, 0). Those are blank, +despite their nonzero contour count. Exclude the controls; retain U+0020 only +for Classic's intentional blank atlas slot, described below. +Space has advance width 512; the two control characters have zero advance. +There are no shared-glyph-ID aliases in the Unicode cmap and no other exactly +identical coordinate/contour/flag outlines. The Classic font also has unmapped, +empty `.notdef` and `nonmarkingreturn` glyphs; Resurrections has an empty +`.notdef` glyph. Glyph counts therefore differ from drawable character counts. + +## Website atlas equivalence + +Compared the pinned upstream `assets/matrixcode_msdf.png` (512×512, 8×8 cells) +and `assets/resurrections_msdf.png` (832×768, 13×12 cells) against Pillow/FreeType +renders of every Unicode-mapped character in the corresponding TTF. Atlas +indices below are zero-based, row-major from the top left; each cell is 64×64. +The website uses the first 57 and 135 slots respectively. + +Extracted atlas masks with `median(R, G, B) > 127`, cropped each mask to its +ink bounds, resized to fit a 96×96 canvas **preserving aspect ratio**, and +compared intersection-over-union against similarly normalized TTF masks +rendered at 512 pixels. Every nonblank active slot had a unique best match. +Lowest winning IoU: Classic 0.8673, Resurrections 0.8524. Smallest winning +margin over the runner-up: Classic 0.1420, Resurrections 0.0808. Normalizing +width and height independently is unsuitable: it confuses squares with bars. + +**Classic website sequence (57 slots):** + +```text +30DB 30A2 0033 30A6 blank 30BB 00A6 003A +0022 E937 30DF 30E9 30EA 254C 30C4 30C6 +30CB 30CF 30BD 25AA 30FC 003C 003E 0030 +007C 002B 002A 30B3 30B7 30DE 30E0 30E1 +30E2 30A8 30E4 30AD 30AA 30AB 0037 30B1 +30B5 30B9 007A 0031 0035 0032 30E8 30BF +30EF 0034 30CD 30CC 30CA 0039 0038 30D2 +0030 +``` + +- Slot 4 is entirely black in the original RGB texture: a genuine blank slot. + Represent it explicitly as blank (or U+0020 in a fixed-cell renderer). +- Slots 23 and 56 have identical thresholded masks and both match U+0030. + Preserve both entries to reproduce the website's weighting. +- Thus the 57 slots comprise **55 distinct visible glyphs**, one duplicate + zero, and one blank. The visible set is the Classic TTF repertoire above + **minus U+00A9 and U+A78A**. U+A78A matches inactive atlas slot 57; + U+00A9 is absent from the active atlas set. + +**Resurrections website sequence:** exactly **U+E900–U+E986 inclusive**, +135 distinct visible glyphs, with slot `i` matching `U+E900 + i` for all +`0 <= i < 135`. All matches were checked, not inferred from the endpoints. +No active blank slot or repeated character mapping. Slots 135–152 are blank; +slots 153–155 match the three excluded TTF characters U+E987–U+E989. + +Atlas SHA-256 checksums (investigation inputs, not bundled assets): + +```text +1fd4ed52f8e9cc4af6eaad69d28cd289bf6486f15f00c952548ff0533d6a83ab matrixcode_msdf.png +f2bfe884b08cacf6c8be09573442d51c5dc5e97bbd8f86bcd86177d4e4b14c79 resurrections_msdf.png +``` + +Validation: downloaded font/license files were compared byte-for-byte with the pinned raw +upstream files, SHA-256 hashed, and parsed with FontTools checksum checking. +Native CoreText rendering and packaged-bundle loading are separate integration +checks and were not performed as part of this asset import. diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index c78f4e5..321af65 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -29,6 +29,8 @@ struct MatrixScreenSaverOptions: Equatable { static let characters = "Characters" /// Defaults key for the selected rain font families. static let rainFonts = "RainFonts" + /// Independent of custom characters and fonts so switching presets preserves both. + static let rainGlyphSet = "RainGlyphSet" static let scanLinesIntensity = "ScanLinesIntensity" static let scanLinesVertical = "ScanLinesVertical" } @@ -96,12 +98,10 @@ struct MatrixScreenSaverOptions: Equatable { static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." static let errorRateDescription = "Character change rate factor. Default: 1.0." static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default." - /// Help text shown under the Fonts picker. - static let rainFontsDescription = "Mix up to 8 fonts." + /// Help text shown under the Font picker. + static let rainFontsDescription = "Choose a font for the default glyph set." /// Default selection: no fonts, which keeps the built-in glyphs and system font. static let defaultRainFonts: [String] = [] - /// Maximum number of rain fonts that can be mixed. - static let maximumRainFonts = 8 static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled @@ -127,8 +127,10 @@ struct MatrixScreenSaverOptions: Equatable { var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters - /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs. + /// Zero or one selected font; the array format preserves existing saved preferences. var rainFonts = defaultRainFonts + /// Bundled film glyphs or the existing configurable rain repertoire. + var rainGlyphSet: RainGlyphSet = .default var scanLinesIntensity = defaultScanLinesIntensity var scanLinesVertical = defaultScanLinesVertical @@ -168,7 +170,8 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, - rainFonts: rainFonts, + rainFonts: Array(rainFonts.prefix(1)), + rainGlyphSet: rainGlyphSet, neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview @@ -224,14 +227,11 @@ struct MatrixScreenSaverOptions: Equatable { .map { String($0) } .joined() }(), - rainFonts: { - var seen = Set() - return rainFonts - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty && seen.insert($0).inserted } - .prefix(Self.maximumRainFonts) - .map { $0 } - }(), + rainFonts: Array(rainFonts + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .prefix(1)), + rainGlyphSet: rainGlyphSet, scanLinesIntensity: max(0, min(1, scanLinesIntensity)), scanLinesVertical: scanLinesVertical ) @@ -261,6 +261,7 @@ struct MatrixScreenSaverOptions: Equatable { Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, Keys.rainFonts: defaultRainFonts, + Keys.rainGlyphSet: RainGlyphSet.default.rawValue, Keys.scanLinesIntensity: defaultScanLinesIntensity, Keys.scanLinesVertical: defaultScanLinesVertical, ]) @@ -290,6 +291,7 @@ struct MatrixScreenSaverOptions: Equatable { errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, rainFonts: (defaults.array(forKey: Keys.rainFonts) as? [String]) ?? defaultRainFonts, + rainGlyphSet: RainGlyphSet(rawValue: defaults.string(forKey: Keys.rainGlyphSet) ?? "") ?? .default, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical) ).sanitized() @@ -319,12 +321,18 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) defaults.set(options.rainFonts, forKey: Keys.rainFonts) + defaults.set(options.rainGlyphSet.rawValue, forKey: Keys.rainGlyphSet) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) defaults.set(options.scanLinesVertical, forKey: Keys.scanLinesVertical) defaults.synchronize() } } +/// 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 { @@ -439,9 +447,15 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") - /// Pull-down listing installed fonts; ticked entries are the selection. + /// Pull-down listing installed fonts with one selected entry. private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true) - /// Fonts currently ticked in the pull-down, in selection order. + private let rainFontsResetButton = NSButton(title: "Reset", target: nil, action: nil) + /// Radio buttons share a parent and action for native keyboard navigation. + private let rainGlyphRadios = RainGlyphSet.allCases.map { NSButton(radioButtonWithTitle: $0.title, target: nil, action: nil) } + /// Wrapping native text uses the same font fallback as the renderer. + private let rainGlyphPreview = NSTextField(wrappingLabelWithString: "") + private var selectedRainGlyphSet: RainGlyphSet = .default + /// Zero or one selected font, kept in the persisted array format. private var selectedRainFonts: [String] = [] private var didClose = false @@ -491,7 +505,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters - selectedRainFonts = options.rainFonts + selectedRainFonts = options.sanitized().rainFonts + selectedRainGlyphSet = options.rainGlyphSet + updateRainGlyphControls() rebuildRainFontsMenu() updateSceneControls() sizeWindowToFitContent() @@ -557,7 +573,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainDensityField.delegate = self rainDurationField.delegate = self frameRateField.delegate = self - errorRateField.delegate = self + errorRateField.delegate = self + charactersField.delegate = self rainRunForeverCheckbox.target = self rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:)) for checkbox in [neoMessageSceneCheckbox, cmdSceneCheckbox, numberSceneCheckbox] { @@ -599,8 +616,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), - makeRainDurationSection(), - makeRainFontsSection(), + makeRainDurationSection(), + makeRainGlyphSection(), + makeRainFontsSection(), makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), ])) @@ -1129,6 +1147,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Saves edits before scrolling recycles a row's text field. func controlTextDidChange(_ notification: Notification) { guard let field = notification.object as? NSTextField else { return } + if field === charactersField { + updateRainGlyphPreview() + return + } let row = linesTableView.row(for: field) guard editorLines.indices.contains(row) else { return } editorLines[row] = field.stringValue @@ -1232,6 +1254,64 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + private func makeRainGlyphSection() -> NSView { + let heading = NSTextField(labelWithString: "Glyph set") + heading.font = .systemFont(ofSize: 13, weight: .semibold) + for (index, radio) in rainGlyphRadios.enumerated() { + radio.tag = index + radio.target = self + radio.action = #selector(rainGlyphSetChanged(_:)) + } + let row = NSStackView(views: rainGlyphRadios) + row.spacing = 12 + rainGlyphPreview.drawsBackground = true + rainGlyphPreview.backgroundColor = .black + rainGlyphPreview.textColor = .green + rainGlyphPreview.lineBreakMode = .byCharWrapping + rainGlyphPreview.maximumNumberOfLines = 0 + rainGlyphPreview.setAccessibilityLabel("Selected rain glyphs") + let stack = NSStackView(views: [heading, row, rainGlyphPreview]) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + rainGlyphPreview.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + row.toolTip = "Classic and Resurrections use their original bundled fonts. Default uses the settings below." + return stack + } + + @objc private func rainGlyphSetChanged(_ sender: NSButton) { + selectedRainGlyphSet = RainGlyphSet.allCases[sender.tag] + updateRainGlyphControls() + } + + /// Film glyphs require their matching font, so custom settings remain saved but inactive. + private func updateRainGlyphControls() { + for (preset, radio) in zip(RainGlyphSet.allCases, rainGlyphRadios) { + radio.state = preset == selectedRainGlyphSet ? .on : .off + } + let customEnabled = selectedRainGlyphSet == .default + rainFontsButton.isEnabled = customEnabled + rainFontsResetButton.isEnabled = customEnabled + charactersField.isEnabled = customEnabled + updateRainGlyphPreview() + } + + /// Reads the renderer's actual repertoire so custom filtering and preset weighting stay consistent. + private func updateRainGlyphPreview() { + var options = MatrixScreenSaverOptions() + options.rainGlyphSet = selectedRainGlyphSet + options.rainFonts = selectedRainFonts + options.characters = charactersField.stringValue + let renderer = NativeMatrixRenderer() + renderer.updateConfiguration(options.sanitized().rendererConfiguration()) + let name = renderer.activeRainFonts.first + rainGlyphPreview.font = name.flatMap { RainFontCatalog.font(named: $0, size: 12) } + ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .medium) + rainGlyphPreview.stringValue = String(String.UnicodeScalarView(renderer.rainGlyphs)) + rainGlyphPreview.invalidateIntrinsicContentSize() + } + /// Builds the rain fonts picker with every discovered font family. private func makeRainFontsSection() -> NSView { let stack = NSStackView() @@ -1240,19 +1320,21 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, stack.spacing = 4 stack.setHuggingPriority(.required, for: .vertical) - let heading = NSTextField(labelWithString: "Fonts") + let heading = NSTextField(labelWithString: "Font") heading.font = .systemFont(ofSize: 13, weight: .semibold) - rainFontsButton.setAccessibilityLabel("Fonts") + rainFontsButton.setAccessibilityLabel("Font") rainFontsButton.translatesAutoresizingMaskIntoConstraints = false rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail - let resetButton = NSButton(title: "Reset", target: self, action: #selector(clearRainFonts(_:))) + let resetButton = rainFontsResetButton + resetButton.target = self + resetButton.action = #selector(clearRainFonts(_:)) resetButton.bezelStyle = .rounded resetButton.controlSize = .small resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize) - resetButton.setAccessibilityLabel("Reset fonts to system default") + resetButton.setAccessibilityLabel("Reset font to system default") resetButton.toolTip = "Use the default system font" let row = NSStackView(views: [rainFontsButton, resetButton]) row.orientation = .horizontal @@ -1273,7 +1355,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, // Resolve the backing family instead of exposing the system alias or face weights. let backingFont = CTFontCreateWithGraphicsFont(CTFontCopyGraphicsFont(systemFont, nil), 16, nil, nil) let defaultTitle = "Default - \(CTFontCopyFamilyName(backingFont))" - let summary = selectedRainFonts.isEmpty ? defaultTitle : selectedRainFonts.joined(separator: ", ") + let summary = selectedRainFonts.first ?? defaultTitle menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: "")) let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "") @@ -1284,7 +1366,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) for family in families { - let item = NSMenuItem(title: family, action: #selector(toggleRainFont(_:)), keyEquivalent: "") + let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") item.target = self item.representedObject = family item.state = selectedRainFonts.contains(family) ? .on : .off @@ -1294,21 +1376,19 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, rainFontsButton.selectItem(at: 0) } - /// Adds or removes the chosen family, ignoring additions beyond the maximum. - @objc private func toggleRainFont(_ sender: NSMenuItem) { + /// Replaces the selected font without toggling an already selected font off. + @objc private func selectRainFont(_ sender: NSMenuItem) { guard let family = sender.representedObject as? String else { return } - if let index = selectedRainFonts.firstIndex(of: family) { - selectedRainFonts.remove(at: index) - } else if selectedRainFonts.count < MatrixScreenSaverOptions.maximumRainFonts { - selectedRainFonts.append(family) - } + selectedRainFonts = [family] rebuildRainFontsMenu() + updateRainGlyphPreview() } /// Clears the selection so the built-in glyphs and system font are used. @objc private func clearRainFonts(_ sender: Any?) { selectedRainFonts = [] rebuildRainFontsMenu() + updateRainGlyphPreview() } /// Builds a labeled text input row with its description. @@ -1435,8 +1515,21 @@ 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 contentView = NSView(frame: NSRect(x: 0, y: 0, width: 520, height: 550)) - item.view = contentView + 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 stack = NSStackView(views: views) stack.orientation = .vertical stack.alignment = .leading @@ -1449,7 +1542,8 @@ 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), - stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), + scrolls ? stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16) + : stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16), ]) for view in views { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true @@ -1520,6 +1614,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), rainFonts: selectedRainFonts, + rainGlyphSet: selectedRainGlyphSet, scanLinesIntensity: scanLinesSlider.doubleValue / 100, scanLinesVertical: scanLinesDirectionPopup.indexOfSelectedItem == 0 ).sanitized() diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 2babb18..02ba423 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -1,6 +1,61 @@ import AppKit +import CoreText import Foundation +/// Rain repertoires; film presets pair their original artwork with the matching bundled font. +enum RainGlyphSet: String, CaseIterable { + /// Existing built-in or user-customized rain. + case `default` + /// Original trilogy glyph artwork. + case classic + /// Expanded Matrix Resurrections glyph artwork. + case resurrections + + /// Display label in the Rain options radio group. + var title: String { + switch self { + case .default: return "Default" + case .classic: return "Classic" + case .resurrections: return "Resurrections" + } + } + + /// Bundled font resource name, also used as the rain cache's font identity. + var fontName: String? { + switch self { + case .default: return nil + case .classic: return "Matrix-Code" + case .resurrections: return "Matrix-Resurrected" + } + } + + /// Website atlas slots, including Classic's blank and repeated zero to preserve their probability. + /// Mapping provenance is recorded in Resources/Fonts/README.md; glyph order.txt is only approximate. + var scalars: [UnicodeScalar]? { + switch self { + case .default: return nil + case .classic: + return Array("ホア3ウ セ¦:\"\u{E937}ミラリ╌ツテニハソ▪ー<>0|+*コシマムメモエヤキオカ7ケサスz152ヨタワ4ネヌナ98ヒ0".unicodeScalars) + case .resurrections: + return (0xE900...0xE986).compactMap(UnicodeScalar.init) + } + } + + /// File-backed descriptors avoid globally registering or depending on installed font versions. + static let fontDescriptors: [String: NSFontDescriptor] = { + var fonts: [String: NSFontDescriptor] = [:] + let bundle = Bundle(for: NativeMatrixRenderer.self) + for preset in allCases { + guard let name = preset.fontName, + let url = bundle.url(forResource: name, withExtension: "ttf", subdirectory: "Fonts"), + let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor], + let descriptor = descriptors.first else { continue } + fonts[name] = descriptor as NSFontDescriptor + } + return fonts + }() +} + final class NativeMatrixRenderer { struct Configuration: Equatable { var neoMessageSceneEnabled = true @@ -24,6 +79,8 @@ final class NativeMatrixRenderer { var characters = "" /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs and system font. var rainFonts: [String] = [] + /// Film presets override rendering without discarding the user's custom fonts or characters. + var rainGlyphSet: RainGlyphSet = .default var neoMessageName = "" var neoMessageLines: [String] = [] /// When true the 10-second multi-screen sync delay is skipped so scenes @@ -53,6 +110,7 @@ final class NativeMatrixRenderer { errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, rainFonts: rainFonts, + rainGlyphSet: rainGlyphSet, neoMessageName: neoMessageName.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.lines(forName: neoMessageName) : neoMessageLines, skipSyncDelay: skipSyncDelay @@ -293,14 +351,15 @@ final class NativeMatrixRenderer { /// - Parameters: /// - fonts: Selected families, with duplicates ignored and at most eight used. /// - characters: Optional custom character restriction applied before sampling. + /// - glyphSet: Film presets supply their complete repertoire instead of sampling the font. /// - Returns: Disjoint private-use keys for every font, including shared characters. - static func makeRainTables(fonts: [String], characters: String = "") -> RainFontTables { + static func makeRainTables(fonts: [String], characters: String = "", glyphSet: RainGlyphSet = .default) -> RainFontTables { var tables = RainFontTables() var seen = Set() let names = fonts.filter { seen.insert($0).inserted }.prefix(8) for (fontIndex, name) in names.enumerated() { var pool = [UnicodeScalar]() - for (index, source) in glyphEntries(forFont: name, characters: characters).enumerated() { + for (index, source) in (glyphSet.scalars ?? glyphEntries(forFont: name, characters: characters)).enumerated() { // Each font owns separate keys so shared glyphs cannot steal another font's weight. let key = UnicodeScalar(0xF0000 + fontIndex * RainFontTables.maximumGlyphsPerFont + index)! pool.append(key) @@ -356,6 +415,11 @@ final class NativeMatrixRenderer { private var configuration = Configuration().sanitized() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool + /// Active rain characters in pool order, resolving private-use cache aliases for the options preview. + var rainGlyphs: [UnicodeScalar] { + activeGlyphPool.map { rainSourceByScalar[$0] ?? $0 } + } + /// When set, each rain glyph picks a selected font at random, then a glyph within it. private var activeMixPools: [[UnicodeScalar]]? = nil /// Glyph tables for the current `activeRainFonts`. @@ -604,9 +668,14 @@ final class NativeMatrixRenderer { pendingSimulationSteps = 0.0 } - if sanitizedConfiguration.rainFonts != activeRainFonts || sanitizedConfiguration.characters != previousConfiguration.characters { - activeRainFonts = sanitizedConfiguration.rainFonts - rainTables = Self.makeRainTables(fonts: activeRainFonts, characters: sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines)) + let glyphsChanged = sanitizedConfiguration.rainFonts != previousConfiguration.rainFonts || + sanitizedConfiguration.characters != previousConfiguration.characters || + sanitizedConfiguration.rainGlyphSet != previousConfiguration.rainGlyphSet + if glyphsChanged { + activeRainFonts = sanitizedConfiguration.rainGlyphSet.fontName.map { [$0] } ?? sanitizedConfiguration.rainFonts + rainTables = Self.makeRainTables(fonts: activeRainFonts, + characters: sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines), + glyphSet: sanitizedConfiguration.rainGlyphSet) rainFontByScalar = rainTables.fontByScalar rainSourceByScalar = rainTables.sourceByScalar } @@ -633,7 +702,7 @@ final class NativeMatrixRenderer { resetLayers() beginSceneSequence() } else if columns > 0, rows > 0, activeScene == .rainForever { - if previousConfiguration.rainFonts != sanitizedConfiguration.rainFonts || previousConfiguration.characters != sanitizedConfiguration.characters { + if glyphsChanged { resetLayers() } constructRenderContent() @@ -1712,6 +1781,9 @@ enum RainFontCatalog { /// Resolves a family, PostScript or full font name to a font. static func font(named name: String, size: CGFloat) -> NSFont? { + if let descriptor = RainGlyphSet.fontDescriptors[name] { + return NSFont(descriptor: descriptor, size: size) + } if fontFileInventory == nil { refreshHiddenFonts() } if let descriptor = hiddenFonts[name] { // A descriptor retains the file URL; resolving a hidden name alone may substitute Times. diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index ab55a72..e2636b7 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -3,6 +3,7 @@ import Foundation func nativeMatrixRendererTests() { rainFontTests() + rainGlyphPresetTests() commandCaptureLifecycleTests() customTraceHandoffTests() @@ -248,6 +249,34 @@ private func rainFontTests() { "RainFont: rendered rain mixes selected fonts evenly with fallback-enabled glyph pools") } +/// Exercises bundled preset coverage and switching back to the retained custom rain settings. +private func rainGlyphPresetTests() { + let renderer = NativeMatrixRenderer() + var config = NativeMatrixRenderer.Configuration() + config.rainFonts = ["Menlo"] + config.characters = "ATGC" + renderer.updateConfiguration(config) + let customSources = renderer.rainSourceByScalar + for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] { + config.rainGlyphSet = preset + renderer.updateConfiguration(config) + ok(renderer.rainSourceByScalar.count == count && renderer.activeRainFonts == [preset.fontName!], + "Rain presets: \(preset.rawValue) uses its complete bundled glyph set instead of custom characters") + let font = RainFontCatalog.font(named: preset.fontName!, size: 16) + let loadedURL = font.flatMap { CTFontCopyAttribute($0, kCTFontURLAttribute) as? URL } + let bundledURL = Bundle(for: NativeMatrixRenderer.self).url(forResource: preset.fontName!, withExtension: "ttf", subdirectory: "Fonts") + let sources = Array(renderer.rainSourceByScalar.values) + let coverage = font.map { CTFontCopyCharacterSet($0) as CharacterSet } + ok(loadedURL != nil && loadedURL?.resolvingSymlinksInPath() == bundledURL?.resolvingSymlinksInPath() && + sources.count == count && sources.allSatisfy { coverage?.contains($0) == true }, + "Rain presets: \(preset.rawValue) resolves every glyph from its bundled font") + } + config.rainGlyphSet = .default + renderer.updateConfiguration(config) + ok(renderer.activeRainFonts == ["Menlo"] && renderer.rainSourceByScalar == customSources, + "Rain presets: Default restores the custom font and character pool") +} + /// Checks idle intro frames and cursor-only updates through the host's dirty-row contract. private func introDirtyRowTests() { for neo in [true, false] { diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 34da38b..c348cf0 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -5,6 +5,7 @@ import ScreenSaver func sharedSaverTests() { glyphCacheTests() rainFontResetTests() + rainGlyphOptionsTests() firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), @@ -139,15 +140,16 @@ func sharedSaverTests() { check(fontOptions.rainFonts.isEmpty, "rain fonts default to none, keeping the built-in glyphs") fontOptions.rainFonts = [" Matrix-Code ", "Matrix-Code", "", "Matrix-Resurrected"] + (1...10).map { "Font \($0)" } let cleanFonts = fontOptions.sanitized().rainFonts - check(cleanFonts.prefix(2) == ["Matrix-Code", "Matrix-Resurrected"] && Set(cleanFonts).count == cleanFonts.count, - "rain font sanitizing trims names and drops blanks and duplicates") - check(cleanFonts.count == MatrixScreenSaverOptions.maximumRainFonts, "rain font sanitizing caps the selection") + check(cleanFonts == ["Matrix-Code"], "rain font sanitizing retains only the first nonempty trimmed name") + defaults.set(["", " Menlo ", "Hiragino Sans"], forKey: "RainFonts") + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Menlo"], + "existing multi-font preferences migrate to the first nonempty font") fontOptions.rainFonts = ["Matrix-Code", "Matrix-Resurrected"] fontOptions.save(to: defaults) - check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], - "rain fonts persist through save and load") - check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code", "Matrix-Resurrected"], - "rain fonts reach the renderer configuration") + check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code"], + "only one rain font persists through save and load") + check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code"], + "only one selected font reaches the renderer configuration") let original = MatrixScreenSaverOptions() var commandOptions = original @@ -259,8 +261,17 @@ private func rainFontResetTests() { window.contentView!.layoutSubtreeIfNeeded() let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } - check(reset?.controlSize == .small && picker?.title.contains("Menlo") == true, + check(reset?.controlSize == .small && picker?.title == "Menlo" && + picker?.menu?.items.filter { $0.state == .on }.count == 1, "font picker exposes a small reset button beside a custom selection") + for _ in 0..<2 { + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) { + menu.performActionForItem(at: index) + } + check(picker?.title == "Hiragino Sans" && picker?.menu?.items.filter { $0.state == .on }.count == 1 && + picker?.menu?.items.first { $0.title == "Menlo" }?.state == .off, + "choosing a font replaces the previous selection and selecting it again keeps it selected") + } reset?.performClick(nil) check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && picker?.menu?.items.dropFirst(3).allSatisfy { $0.state == .off } == true, @@ -270,6 +281,88 @@ private func rainFontResetTests() { "font reset applies the default font without changing other options") } +/// Exercises preset persistence, exclusive radio selection, cancellation and retained customization. +private func rainGlyphOptionsTests() { + let domain = "dev.patsch.MatrixWallpaper.glyph-tests.\(UUID().uuidString)" + let defaults = ScreenSaverDefaults(forModuleWithName: domain)! + defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() } + MatrixScreenSaverOptions.registerDefaults(in: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default, + "existing installations keep the default glyph set") + defaults.set("unknown-preset", forKey: "RainGlyphSet") + check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default, + "unknown persisted glyph sets safely use Default") + var options = MatrixScreenSaverOptions() + options.characters = "ATGC" + options.rainFonts = ["Menlo"] + for preset in RainGlyphSet.allCases { + options.rainGlyphSet = preset + options.save(to: defaults) + let loaded = MatrixScreenSaverOptions.load(from: defaults) + check(loaded.rainGlyphSet == preset && loaded.rendererConfiguration().rainGlyphSet == preset && + loaded.characters == "ATGC" && loaded.rainFonts == ["Menlo"], + "\(preset.title) persists and reaches the renderer without overwriting customization") + } + var applied: MatrixScreenSaverOptions? + let controller = MatrixScreenSaverOptionsSheetController { applied = $0 } + controller.prepare(using: options) + let window = controller.configureSheet() + let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first! + let rainTab = tabs.tabViewItems.first { $0.label == "Rain" }! + tabs.selectTabViewItem(rainTab) + let controls = buttons(in: rainTab.view!) + let radios = RainGlyphSet.allCases.compactMap { preset in controls.first { $0.title == preset.title } } + let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) + let characters: NSTextField? = stored("charactersField", in: controller) + let reset = controls.first { $0.title == "Reset" } + let preview: NSTextField? = stored("rainGlyphPreview", in: controller) + check(radios.count == 3 && radios.last?.state == .on && picker?.isEnabled == false, + "Rain restores the saved Resurrections radio with custom font controls inactive") + guard radios.count == 3 else { return } + for (index, radio) in radios.enumerated() { + radio.performClick(nil) + check(radios.enumerated().allSatisfy { $0.element.state == ($0.offset == index ? .on : .off) }, + "\(radio.title) is mutually exclusive with the other glyph presets") + check(picker?.isEnabled == (index == 0) && characters?.isEnabled == (index == 0) && reset?.isEnabled == (index == 0), + "\(radio.title) enables custom settings only for Default") + let expectedCount = [4, 57, 135][index] + check(preview?.stringValue.unicodeScalars.count == expectedCount && preview?.drawsBackground == true && + preview?.backgroundColor == .black && preview?.textColor == .green, + "\(radio.title) previews every active glyph in green on black") + let expectedFont = index == 0 ? "Menlo-Regular" : ["Matrix-Code", "Matrix-Resurrected"][index - 1] + 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(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!) + radios[0].performClick(nil) + check(characters?.stringValue == "ATGC" && picker?.title.contains("Menlo") == true, + "switching back to Default restores editable custom settings") + characters?.stringValue = "XYZ" + controller.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: characters)) + check(preview?.stringValue == "XYZ", "editing custom characters refreshes the glyph preview immediately") + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) { + menu.performActionForItem(at: index) + } + check(preview?.font?.familyName == "Hiragino Sans", "choosing another font refreshes the preview typeface") + reset?.performClick(nil) + check(preview?.font?.fontName == NSFont.monospacedSystemFont(ofSize: 16, weight: .medium).fontName, + "font Reset restores the system typeface in the preview") + 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) + 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) + check(!cancelled && MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .resurrections, + "Cancel does not persist a changed glyph preset") +} + /// Checks raster reuse through layout and option updates, retaining images to compare identity safely. private func glyphCacheTests() { let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)! @@ -368,6 +461,26 @@ private func glyphCacheTests() { } else { check(false, "wide selected-font glyphs fit inside the cell without cutting solid strokes") } + for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] { + let previousRaster = glyph(rainKey) + options.rainGlyphSet = preset + view.applyWallpaperOptions(options) + let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view)! + let resolved: [String: NSFont] = stored("rainFonts", in: view) ?? [:] + let font = resolved[preset.fontName!] + let usesOriginalFont = font.map { CTFontCopyPostScriptName($0) as String == preset.fontName } ?? false + check(usesOriginalFont && renderer.rainSourceByScalar.count == count && + renderer.rainSourceByScalar.keys.allSatisfy { glyph($0) != nil } && previousRaster !== glyph(rainKey), + "\(preset.title) rebuilds all preset rasters using the original bundled font") + let cached = glyph(rainKey) + view.applyWallpaperOptions(options) + check(cached != nil && cached === glyph(rainKey) && cached === glyph(rainKey, bold: true), + "\(preset.title) reuses cached artwork across unchanged options and font weights") + } + options.rainGlyphSet = .default + view.applyWallpaperOptions(options) + check(glyph(UnicodeScalar(0xF0001)!) == nil && glyph(rainKey) != nil, + "returning to custom rain removes stale film-preset glyphs") var capturedOptions = MatrixScreenSaverOptions() capturedOptions.neoMessageSceneEnabled = false capturedOptions.cmdSceneEnabled = true @@ -412,16 +525,20 @@ private func checkOptionsLayout(in window: NSWindow) { for item in tabs.tabViewItems { tabs.selectTabViewItem(item) content.layoutSubtreeIfNeeded() - let page = item.view! + 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)) }, "\(item.label) keeps every control and help label inside its page") let sections = (page.subviews.first as? NSStackView)?.arrangedSubviews ?? [] - let top = sections.map { $0.convert($0.bounds, to: page).maxY }.max() ?? 0 - check((12...20).contains(page.bounds.maxY - top), "\(item.label) starts with a consistent small top margin") + let frames = sections.map { $0.convert($0.bounds, to: page) } + let topMargin = page.isFlipped ? (frames.map(\.minY).min() ?? 0) - page.bounds.minY + : page.bounds.maxY - (frames.map(\.maxY).max() ?? 0) + check((12...20).contains(topMargin), "\(item.label) starts with a consistent small top margin") if let stack = page.subviews.first as? NSStackView { let sections = stack.arrangedSubviews - check(zip(sections, sections.dropFirst()).allSatisfy { $0.frame.minY >= $1.frame.maxY }, + check(zip(sections, sections.dropFirst()).allSatisfy { + stack.isFlipped ? $0.frame.maxY <= $1.frame.minY : $0.frame.minY >= $1.frame.maxY + }, "\(item.label) sections do not overlap") } if let forever = controls.compactMap({ $0 as? NSButton }).first(where: { $0.title == "Run forever" }), diff --git a/build-wallpaper.sh b/build-wallpaper.sh index 4603728..9d5ef1c 100755 --- a/build-wallpaper.sh +++ b/build-wallpaper.sh @@ -36,6 +36,8 @@ swiftc -O -module-name MatrixWallpaper \ -o "$BUNDLE_DIR/Contents/MacOS/MatrixWallpaper" 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/" /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" diff --git a/build.sh b/build.sh index 7ed2ca3..4db86f1 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,7 @@ swiftc \ "$SOURCE_DIR/MatrixScreenSaverView.swift" cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" +cp -R "$INPUT_RESOURCES_DIR/Fonts" "$RESOURCES_DIR/Fonts" 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/tests.sh b/tests.sh index 196e6a2..3a04d22 100755 --- a/tests.sh +++ b/tests.sh @@ -4,7 +4,13 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd)" SRC="$ROOT/Sources/MatrixScreenSaver" TESTS="$ROOT/Tests/MatrixScreenSaverTests" -TEST_BIN=/tmp/matrix_screensaver_tests +# A bundle exercises the same font-resource lookup used by both shipping products. +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-screensaver-tests.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents" +TEST_BIN="$TEST_CONTENTS/MacOS/matrix_screensaver_tests" +mkdir -p "$TEST_CONTENTS/MacOS" "$TEST_CONTENTS/Resources" +cp -R "$ROOT/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts" swiftc \ -framework Foundation \ diff --git a/wallpaper-tests.sh b/wallpaper-tests.sh index a55328a..2f833fa 100755 --- a/wallpaper-tests.sh +++ b/wallpaper-tests.sh @@ -5,9 +5,14 @@ ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_DIR="$ROOT_DIR/Sources/MatrixScreenSaver" WALLPAPER_DIR="$ROOT_DIR/Sources/MatrixWallpaper" TEST_DIR="$ROOT_DIR/Tests/MatrixWallpaperTests" -TEST_BIN="$(mktemp "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")" -HOST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-consumer-tests.XXXXXX")" -trap 'rm -f "$TEST_BIN" "$HOST_DIR/ScreenSaverEngine"; rmdir "$HOST_DIR"' EXIT +# Keep bundled font lookup identical in the app tests and the consumer-host copy. +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")" +trap 'rm -rf "$TEST_ROOT"' EXIT +TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents" +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" OPTIMIZATION="-Onone" if [[ "${1:-}" == "--gpu-composition-benchmark" || "${1:-}" == "--composition-benchmark" ]]; then From 81ddef79ef7bb01fee6024e02f8105b26faf20f0 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:36:20 +0100 Subject: [PATCH 07/17] feat: list bundled fonts below the system default - show both bundled fonts before the dropdown divider - avoid duplicate entries among installed fonts - verify selection, ordering and reset behavior --- .../MatrixScreenSaverOptions.swift | 21 +++++++++++-------- .../SharedSaverTests.swift | 14 ++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 321af65..e4b2946 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1362,15 +1362,18 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, defaultItem.target = self defaultItem.state = selectedRainFonts.isEmpty ? .on : .off menu.addItem(defaultItem) - menu.addItem(.separator()) - - let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts) - for family in families { - let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") - item.target = self - item.representedObject = family - item.state = selectedRainFonts.contains(family) ? .on : .off - menu.addItem(item) + + let bundledFonts = RainGlyphSet.allCases.compactMap(\.fontName) + let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts).filter { !bundledFonts.contains($0) } + for (index, group) in [bundledFonts, families].enumerated() { + if index == 1 { menu.addItem(.separator()) } + for family in group { + let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "") + item.target = self + item.representedObject = family + item.state = selectedRainFonts.contains(family) ? .on : .off + menu.addItem(item) + } } rainFontsButton.menu = menu rainFontsButton.selectItem(at: 0) diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index c348cf0..3f585c7 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -261,6 +261,11 @@ private func rainFontResetTests() { window.contentView!.layoutSubtreeIfNeeded() let picker: NSPopUpButton? = stored("rainFontsButton", in: controller) let reset = buttons(in: window.contentView!).first { $0.title == "Reset" } + let bundledNames = ["Matrix-Code", "Matrix-Resurrected"] + let items = picker?.menu?.items ?? [] + check(items.count > 4 && Array(items.dropFirst(2).prefix(2).map(\.title)) == bundledNames && items[4].isSeparatorItem && + bundledNames.allSatisfy { name in items.filter { $0.representedObject as? String == name }.count == 1 }, + "bundled fonts appear once directly after Default and before the divider") check(reset?.controlSize == .small && picker?.title == "Menlo" && picker?.menu?.items.filter { $0.state == .on }.count == 1, "font picker exposes a small reset button beside a custom selection") @@ -272,9 +277,16 @@ private func rainFontResetTests() { picker?.menu?.items.first { $0.title == "Menlo" }?.state == .off, "choosing a font replaces the previous selection and selecting it again keeps it selected") } + for name in bundledNames { + if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == name }) { + menu.performActionForItem(at: index) + } + check(picker?.title == name && picker?.menu?.items.filter { $0.state == .on }.count == 1, + "bundled font \(name) can be selected from the dropdown") + } reset?.performClick(nil) check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on && - picker?.menu?.items.dropFirst(3).allSatisfy { $0.state == .off } == true, + 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) check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42, From 1fa958dfbe1ac1e226a7e8c547be1fe890052c8b Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 30 Oct 2026 19:58:06 +0100 Subject: [PATCH 08/17] test: allow recovery fixtures to finish queued startup - match the existing ten-second AppKit startup allowance - stop and clean up fixtures when startup fails --- Tests/MatrixWallpaperTests/WallpaperAppTests.swift | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index b42953b..444aafb 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -373,7 +373,13 @@ private func wallpaperTerminationRecoveryTests() { let controller = desktop.controller() let host = WallpaperApp(defaults: defaults, desktopWallpaper: controller) host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) - check(eventually { desktopWindowCount() == NSScreen.screens.count }, "recovery fixture awaits launch playback") + // Match the background-failure fixture's allowance for queued AppKit startup on CI. + let started = eventually(10) { desktopWindowCount() == NSScreen.screens.count } + check(started, "recovery fixture awaits launch playback") + guard started else { + host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + return + } desktop.unreadable = ["first"] host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) check(NSApp.modalWindow == nil && desktopWindowCount() == 0, @@ -385,12 +391,14 @@ private func wallpaperTerminationRecoveryTests() { desktop.images["first"] = .init(url: URL(fileURLWithPath: "/new-after-failed-quit.jpg"), options: [:]) let relaunched = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller()) relaunched.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) - check(eventually { desktopWindowCount() == NSScreen.screens.count }, "relaunch awaits recovery and playback") + defer { relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + let restarted = eventually(10) { desktopWindowCount() == NSScreen.screens.count } + check(restarted, "relaunch awaits recovery and playback") + guard restarted else { return } check(desktop.images.values.allSatisfy { $0.url == controller.blackImageURL }, "relaunch recovers stale entries and starts black") NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: relaunched, from: nil) check(desktop.images["first"]?.url.path == "/new-after-failed-quit.jpg", "relaunch captures a newer user choice instead of restoring a stale original") - relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } /// Exercises installation discovery and persistent menu state without touching real installations. From 50c39505d241b74cfeb9bddfd8e4bd97dd60d72e Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 13:22:03 +0100 Subject: [PATCH 09/17] feat: rename wallpaper app and add playback controls - rename app bundles and preserve installer and Homebrew upgrades - add same-row Start/Stop and Pause/Resume text buttons - group screen saver sync below Launch at Login - cover paused playback, menu layout, and upgrade rollback --- .github/workflows/release.yml | 8 +- Homebrew/Casks/matrix-screen-saver.rb | 10 +-- Homebrew/README.md | 30 ++++--- README.md | 22 +++-- Scripts/notarize.sh | 2 +- Scripts/render-homebrew-release.py | 14 +++- Scripts/update-homebrew-tap.py | 15 +++- Sources/MatrixWallpaper/WallpaperApp.swift | 84 +++++++++++++------ .../WallpaperPlaybackState.swift | 5 +- Tests/Homebrew/cask_lifecycle_test.rb | 5 +- Tests/Homebrew/documentation_test.py | 2 +- Tests/Homebrew/fixtures/legacy-wallpaper.rb | 62 ++++++++++++++ Tests/Homebrew/fixtures/release_fixture.py | 8 +- Tests/Homebrew/integration_lifecycle.sh | 13 ++- Tests/Homebrew/release_metadata_test.py | 34 ++++++-- Tests/Homebrew/support/fake_cask_dsl.rb | 4 +- Tests/Homebrew/support/integration_driver.rb | 51 ++++++++++- Tests/Homebrew/tap_update_test.py | 23 ++++- .../WallpaperPlaybackStateTests.swift | 14 ++++ .../WallpaperAppTests.swift | 76 ++++++++++++++++- Tests/Signing/signing_test.py | 9 +- build-wallpaper.sh | 2 +- install-wallpaper.sh | 28 +++++-- wallpaper-install-tests.sh | 73 ++++++++++++++-- 24 files changed, 488 insertions(+), 106 deletions(-) create mode 100644 Tests/Homebrew/fixtures/legacy-wallpaper.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ef7a364..601d3f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,7 +134,7 @@ jobs: ./build.sh ./build-wallpaper.sh codesign --verify --deep --strict "build/MatrixScreenSaver.saver" - codesign --verify --deep --strict "build/MatrixWallpaper.app" + codesign --verify --deep --strict "build/Matrix Wallpaper.app" - name: Update CHANGELOG id: changelog @@ -456,9 +456,9 @@ jobs: ./build.sh ./build-wallpaper.sh bash Scripts/sign-bundle.sh "build/MatrixScreenSaver.saver" - bash Scripts/sign-bundle.sh "build/MatrixWallpaper.app" + bash Scripts/sign-bundle.sh "build/Matrix Wallpaper.app" codesign --verify --deep --strict "build/MatrixScreenSaver.saver" - codesign --verify --deep --strict "build/MatrixWallpaper.app" + codesign --verify --deep --strict "build/Matrix Wallpaper.app" - name: Notarize and staple both bundles if: steps.release.outputs.exists != 'true' @@ -491,7 +491,7 @@ jobs: rm -rf "${release_dir}" mkdir -p "${release_dir}" ditto "build/MatrixScreenSaver.saver" "${release_dir}/MatrixScreenSaver.saver" - ditto "build/MatrixWallpaper.app" "${release_dir}/MatrixWallpaper.app" + ditto "build/Matrix Wallpaper.app" "${release_dir}/Matrix Wallpaper.app" echo "dir=${release_dir}" >> "$GITHUB_OUTPUT" diff --git a/Homebrew/Casks/matrix-screen-saver.rb b/Homebrew/Casks/matrix-screen-saver.rb index 6d6766a..7da1eb2 100644 --- a/Homebrew/Casks/matrix-screen-saver.rb +++ b/Homebrew/Casks/matrix-screen-saver.rb @@ -10,7 +10,7 @@ depends_on arch: :arm64 depends_on macos: :sequoia - app "MatrixWallpaper.app" + app "MatrixWallpaper.app", target: "Matrix Wallpaper.app" screen_saver "MatrixScreenSaver.saver" preflight_steps do @@ -24,13 +24,13 @@ writable_paths: [{ path: "MatrixScreenSaver.saver", base: :screen_saverdir }] end - if_path_exists "MatrixWallpaper.app", base: :appdir do + if_path_exists "Matrix Wallpaper.app", base: :appdir do run "/bin/echo", - args: ["Existing app at {{appdir}}/MatrixWallpaper.app. " \ + args: ["Existing app at {{appdir}}/Matrix Wallpaper.app. " \ "To replace it while retaining options, run: " \ "brew install --cask --force patrickschaper/tap/matrix-screen-saver"], print_stdout: true, - writable_paths: [{ path: "MatrixWallpaper.app", base: :appdir }] + writable_paths: [{ path: "Matrix Wallpaper.app", base: :appdir }] end run "/bin/echo", @@ -53,7 +53,7 @@ args: ["Open System Settings > Wallpaper > Screen Saver and select MatrixScreenSaver."], print_stdout: true run "/bin/echo", - args: ["Open {{appdir}}/MatrixWallpaper.app to start Matrix Wallpaper. " \ + args: ["Open {{appdir}}/Matrix Wallpaper.app to start Matrix Wallpaper. " \ "Launch at Login is optional in its Matrix menu."], print_stdout: true end diff --git a/Homebrew/README.md b/Homebrew/README.md index 8b54b54..0ace6dd 100644 --- a/Homebrew/README.md +++ b/Homebrew/README.md @@ -5,9 +5,13 @@ The public tap is [patrickschaper/homebrew-tap](https://github.com/patrickschape that repository. Users add it with `brew tap patrickschaper/tap`. The next release payload uses Homebrew's native `screen_saver` and `app` artifacts, -not the local installers, to manage `MatrixScreenSaver.saver` and `MatrixWallpaper.app` +not the local installers, to manage `MatrixScreenSaver.saver` and `Matrix Wallpaper.app` together. The live tap gains this behavior only after a new dual-bundle release and -tap update; immutable older releases remain saver-only. +tap update; immutable saver-only releases remain saver-only. +The template retains the pinned archive's `MatrixWallpaper.app` source and uses +Homebrew's `target:` to install it as `Matrix Wallpaper.app`. The release renderer +selects the source name from each validated archive, so old dual-bundle downloads +and future renamed bundles both install under the new name. It retains the `dev.patsch.MatrixScreenSaver` preferences domain and has no zap or preference-cleanup hook. Its published binary requires Apple Silicon (ARM64) and macOS 15 (Sequoia) or newer. @@ -24,7 +28,7 @@ brew style "$PWD/Homebrew/Casks/matrix-screen-saver.rb" ```bash brew trust --cask patrickschaper/tap/matrix-screen-saver -brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app +brew install --cask patrickschaper/tap/matrix-screen-saver && open "/Applications/Matrix Wallpaper.app" ``` The command launches the wallpaper app after installation succeeds, using Homebrew's @@ -34,7 +38,7 @@ The fully qualified install trusts this cask, not the entire tap. Explicit `brew trust --cask` also permits subsequent installs using the short name. **Security:** preflight removes quarantine recursively only from the -checksum-verified staged `MatrixScreenSaver.saver` and `MatrixWallpaper.app`, before +checksum-verified staged `MatrixScreenSaver.saver` and wallpaper app, before Homebrew moves them. This bypasses Gatekeeper quarantine checks; it does not make either bundle notarized or Apple-approved. Install only if you trust the release publisher. The cask prints this disclosure before invoking `/usr/bin/xattr`; failure aborts installation. @@ -43,7 +47,7 @@ After installation, open **System Settings > Wallpaper > Screen Saver** and select **MatrixScreenSaver** manually. Modern Homebrew's sandbox blocks opening Settings, so automatic opening was intentionally removed. The cask never selects the saver, launches the wallpaper on a fresh install, enables login startup, or opens -a browser. The command above launches **MatrixWallpaper** from your shell after Homebrew +a browser. The command above launches **Matrix Wallpaper** from your shell after Homebrew finishes; use its single **Launch at Login** checkbox if wanted. Homebrew may reopen an already-running app following an upgrade. Successful install, upgrade, and reinstall print: @@ -79,6 +83,11 @@ Upgrades and reinstalls remain native Homebrew operations. An upgrade may print the existing-target guidance because Homebrew can leave an empty target directory during replacement; no prior-receipt snapshot or initial-install-only behavior is needed. Custom destinations are honored, and other saver copies are not removed. +When upgrading a managed `MatrixWallpaper.app`, Homebrew uses its prior receipt +to remove the old filename and installs `Matrix Wallpaper.app`. A conflict at the +new path aborts the upgrade and restores the previous managed bundles. Unmanaged +old-name copies are not removed; use the local installer to migrate a copy in +`~/Applications`, or remove that copy yourself after confirming the new app works. The native app and screen-saver artifacts are both installed and removed by the same cask. Uninstall requests that `dev.patsch.MatrixWallpaper` quit and asks Homebrew to remove its login items. Homebrew skips login-item cleanup when an @@ -143,10 +152,10 @@ The Release publish job invokes the script with explicit `--publish` opt-in, after validating the published asset and uploading the handoff. It writes only `Casks/matrix-screen-saver.rb` on the tap's default branch; no tap PR or merge is required. This replaces the previous optional, human-merged tap PR behavior. -Only version, SHA-256, and minimum macOS metadata may differ from the source -template. For the first dual-bundle release, the exact frozen saver-only predecessor -is also accepted for a strictly newer version, using its normalized structure hash. -This one reviewed transition does not allow arbitrary structural edits. Unrelated +Only version, SHA-256, minimum macOS metadata, and the validated old/new archive +source name may differ from the source template. The exact frozen saver-only and +old-name dual-bundle predecessors are also accepted for a strictly newer version, +using their normalized structure hashes. These reviewed transitions do not allow arbitrary structural edits. Unrelated cask edits or a missing cask fail closed for manual review. Identical reruns make no commit, older versions are skipped, and conflicting same-version metadata is rejected. Each write includes the current file SHA; @@ -203,7 +212,8 @@ A separate sentinel outside the artifact destination verifies preference retention; it does not access the actual defaults domain. HOME alone is not used as isolation. -Native integration covers saver-only migration, dual install, conflict failure, forced migration, upgrade, +Native integration covers saver-only migration, old-name app migration and rollback, +old/new archive names, dual install, conflict failure, forced migration, upgrade, reinstall, uninstall, quarantine removal, broken-symlink conflict/force semantics, and developer-mode acceptance without legacy warnings on definition load or uninstall. Run workflow lint (for example `actionlint`) separately; never conceal diff --git a/README.md b/README.md index 75040ad..011c1bd 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ The screen saver also comes with an app replacing your wallpaper with the animat ```bash brew trust --cask patrickschaper/tap/matrix-screen-saver -brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app +brew install --cask patrickschaper/tap/matrix-screen-saver && open "/Applications/Matrix Wallpaper.app" ``` -- Wallpaper: open **MatrixWallpaper** in Applications. options available through menubar icon. +- Wallpaper: open **Matrix Wallpaper** in Applications. options available through menubar icon. - Screen saver options: **System Settings → Wallpaper → Screen Saver → MatrixScreenSaver → Options…**. ## Buy me a coffee @@ -108,7 +108,7 @@ These create the screen saver bundle and wallpaper app: ```text build/MatrixScreenSaver.saver -build/MatrixWallpaper.app +build/Matrix Wallpaper.app ``` Both build scripts read the current version from `./VERSION` and write it into their bundle metadata. @@ -145,7 +145,7 @@ This rebuilds the bundle, compiles the preview host, and opens the saver in a no To launch the built wallpaper app without installing: ```bash -open build/MatrixWallpaper.app +open "build/Matrix Wallpaper.app" ``` ### Install @@ -176,11 +176,15 @@ To install and launch the wallpaper app in your user Applications folder: ```bash ./install-wallpaper.sh -open ~/Applications/MatrixWallpaper.app +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 @@ -189,8 +193,12 @@ The choice survives app restarts and saver reinstallation; opening the menu refr 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 **Quit Matrix Wallpaper** -restore those settings. Temporary pauses and animation restarts keep the original backup. +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. While paused, the wallpaper +declines frame sharing so the screen saver animates independently. **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/`. diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh index 30b4fb7..a633fed 100644 --- a/Scripts/notarize.sh +++ b/Scripts/notarize.sh @@ -6,7 +6,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" : "${NOTARY_KEYCHAIN_PROFILE:?Set NOTARY_KEYCHAIN_PROFILE to your notarytool credential profile.}" -BUNDLES=("$ROOT_DIR/build/MatrixScreenSaver.saver" "$ROOT_DIR/build/MatrixWallpaper.app") +BUNDLES=("$ROOT_DIR/build/MatrixScreenSaver.saver" "$ROOT_DIR/build/Matrix Wallpaper.app") for bundle in "${BUNDLES[@]}"; do codesign --verify --deep --strict "$bundle" diff --git a/Scripts/render-homebrew-release.py b/Scripts/render-homebrew-release.py index 3a55482..158ad7c 100644 --- a/Scripts/render-homebrew-release.py +++ b/Scripts/render-homebrew-release.py @@ -69,18 +69,24 @@ def render(archive_path, version, url, digest, provenance, output): names = [entry.filename for entry in entries] if len(names) != len(set(names)) or sum(e.file_size for e in entries) > 100_000_000: raise ValueError("Duplicate or oversized archive entries") + # Published archives are immutable; accept either filename, but never both. + app_roots = {PurePosixPath(name).parts[0] for name in names if PurePosixPath(name).parts} & { + "MatrixWallpaper.app", "Matrix Wallpaper.app"} + if len(app_roots) != 1: + raise ValueError("Expected exactly one wallpaper app bundle") + app_root = app_roots.pop() for entry in entries: path = PurePosixPath(entry.filename) if path.is_absolute() or ".." in path.parts or "\\" in entry.filename: raise ValueError("Unsafe archive path") - if not path.parts or path.parts[0] not in ("MatrixScreenSaver.saver", "MatrixWallpaper.app", "__MACOSX"): + if not path.parts or path.parts[0] not in ("MatrixScreenSaver.saver", app_root, "__MACOSX"): raise ValueError("Unexpected release archive layout") if (entry.external_attr >> 16) & 0o170000 == 0o120000: raise ValueError("Symlinks are not supported in release archives") minimum = (0, 0, 0) identifiers = [] - for name, extension, kind in [("MatrixScreenSaver", "saver", 8), ("MatrixWallpaper", "app", 2)]: - root = f"{name}.{extension}/Contents" + for name, bundle, kind in [("MatrixScreenSaver", "MatrixScreenSaver.saver", 8), ("MatrixWallpaper", app_root, 2)]: + root = f"{bundle}/Contents" info = plistlib.loads(archive.read(f"{root}/Info.plist")) if (info.get("CFBundleIdentifier") != f"dev.patsch.{name}" or info.get("CFBundleShortVersionString") != version @@ -96,7 +102,7 @@ def render(archive_path, version, url, digest, provenance, output): floor, symbol = "26.0", "tahoe" else: raise ValueError("Unsupported macOS floor: update compatibility mapping before release") - cask = TEMPLATE.read_text() + cask = TEMPLATE.read_text().replace("MatrixWallpaper.app", app_root) cask, versions = re.subn(r'^ version "[^"]+"$', f' version "{version}"', cask, flags=re.M) cask, hashes = re.subn(r'^ sha256 "[0-9a-f]{64}"$', f' sha256 "{sha256}"', cask, flags=re.M) cask, floors = re.subn(r'^ depends_on macos: :\w+$', f' depends_on macos: :{symbol}', cask, flags=re.M) diff --git a/Scripts/update-homebrew-tap.py b/Scripts/update-homebrew-tap.py index fcf7285..717fcd3 100644 --- a/Scripts/update-homebrew-tap.py +++ b/Scripts/update-homebrew-tap.py @@ -12,7 +12,11 @@ import urllib.request TEMPLATE = Path(__file__).resolve().parents[1] / "Homebrew/Casks/matrix-screen-saver.rb" -LEGACY_STRUCTURE_SHA256 = "5887ff0f5de0edcb895128458af2b519c4243ebe67b6de3a78374c1934c24e59" +# Reviewed saver-only and old-name dual-bundle predecessors; arbitrary edits still fail closed. +LEGACY_STRUCTURE_SHA256 = { + "5887ff0f5de0edcb895128458af2b519c4243ebe67b6de3a78374c1934c24e59", + "85c52f3fa521d0f2d269ebdae5c56f634b715744d0b261f9ac289ddaa47cf047", +} def cask_metadata(content): @@ -57,7 +61,10 @@ def update(api, target, version, content): if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)", version): raise ValueError("Invalid release version") metadata, structure = cask_metadata(content) - if metadata[0] != version or structure != cask_metadata(TEMPLATE.read_text())[1]: + template = TEMPLATE.read_text() + structures = {cask_metadata(template)[1], + cask_metadata(template.replace("MatrixWallpaper.app", "Matrix Wallpaper.app"))[1]} + if metadata[0] != version or structure not in structures: raise ValueError("Generated cask differs from the release template or version") repo = f"repos/{target}" default = api.request("GET", repo)["default_branch"] @@ -67,8 +74,8 @@ def update(api, target, version, content): raise ValueError("Tap cask is missing or not a regular file; inspect it before publication") current = base64.b64decode(existing["content"]).decode() previous, previous_structure = cask_metadata(current) - if (previous_structure != structure - and hashlib.sha256(previous_structure.encode()).hexdigest() != LEGACY_STRUCTURE_SHA256): + if (previous_structure not in structures + and hashlib.sha256(previous_structure.encode()).hexdigest() not in LEGACY_STRUCTURE_SHA256): raise ValueError("Tap cask has unrelated edits; review the manual handoff (no overwrite)") if tuple(map(int, previous[0].split("."))) > tuple(map(int, version.split("."))): return f"Tap already has newer version {previous[0]}; skipped {version}" diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index a92fab6..2303be2 100644 --- a/Sources/MatrixWallpaper/WallpaperApp.swift +++ b/Sources/MatrixWallpaper/WallpaperApp.swift @@ -55,7 +55,10 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { private var isTerminating = false /// Menu action whose label follows requested playback, not temporary suspension. - private let playbackItem = NSMenuItem(title: "Stop", action: #selector(togglePlayback(_:)), keyEquivalent: "") + private let playbackButton = NSButton(title: "Stop", target: nil, action: #selector(togglePlayback(_:))) + + /// Manual pause remains available independently of temporary system suspension. + private let pauseButton = NSButton(title: "Pause", target: nil, action: #selector(togglePause(_:))) /// Retains native options UI independently of transient wallpaper views. private var optionsController: MatrixScreenSaverOptionsSheetController? @@ -104,15 +107,28 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { let menu = NSMenu() menu.autoenablesItems = false menu.delegate = self - playbackItem.target = self - menu.addItem(playbackItem) + for button in [playbackButton, pauseButton] { + button.target = self + button.bezelStyle = .rounded + } + 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: "") options.target = self menu.addItem(options) syncItem.target = self - menu.addItem(syncItem) 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: "") coffee.target = self @@ -206,10 +222,22 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Starts or stops at the user's request without overriding system suspension. @objc private func togglePlayback(_ sender: Any?) { guard !isTerminating else { return } + statusItem?.menu?.cancelTracking() pendingLaunch?.cancel() pendingLaunch = nil if !playback.isRequested { updateDesktopWallpaper(restoring: true) } playback.isRequested.toggle() + playback.isPaused = false + reconcilePlayback() + } + + /// Freezes or resumes the existing scene without releasing the user's desktop session. + @objc private func togglePause(_ sender: Any?) { + guard !isTerminating, playback.isRequested else { return } + statusItem?.menu?.cancelTracking() + playback.isPaused.toggle() + // A paused producer cannot supply fresh frames; let the saver use its own renderer. + if playback.isPaused { endFrameSharing() } reconcilePlayback() } @@ -255,32 +283,19 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// /// - Parameter restart: Forces coordinated reconstruction after options or display changes. private func reconcilePlayback(restart: Bool = false) { - guard !isTerminating, pendingLaunch == nil else { return } - playbackItem.title = playback.isRequested ? "Stop" : "Start" + guard !isTerminating else { return } + playbackButton.title = playback.isRequested ? "Stop" : "Start" + pauseButton.title = playback.isPaused ? "Resume" : "Pause" + pauseButton.isEnabled = playback.isRequested + guard pendingLaunch == nil else { return } if !playback.isRequested { updateDesktopWallpaper(restoring: true) } if !playback.isRequested || restart { removeDesktopWindows() } guard playback.isRequested else { return } - guard playback.shouldRun else { - if suspendedAt == nil && !windows.isEmpty { - suspendedAt = Date.timeIntervalSinceReferenceDate - for window in windows { - (window.contentView as? MatrixScreenSaverView)?.pauseWallpaperAnimation() - window.orderOut(nil) - } - } - return + if playback.shouldRun || playback.shouldShowDesktop { + updateDesktopWallpaper(restoring: false) } - updateDesktopWallpaper(restoring: false) - guard !isTerminating, playback.shouldRun else { return } - if let suspendedAt { - let duration = Date.timeIntervalSinceReferenceDate - suspendedAt - ScreenSyncCoordinator.shared.shiftTimeline(by: duration) - for window in windows { - (window.contentView as? MatrixScreenSaverView)?.resumeWallpaperAnimation(after: duration) - } - self.suspendedAt = nil - } - if windows.isEmpty { + guard !isTerminating, playback.isRequested else { return } + if windows.isEmpty && (playback.shouldRun || playback.shouldShowDesktop) { currentOptions = MatrixScreenSaverView.loadSaverOptions() displayLayout = currentDisplayLayout for screen in NSScreen.screens { @@ -309,6 +324,21 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { if !window.isVisible { window.orderBack(nil) } } else if window.isVisible { window.orderOut(nil) } } + if playback.shouldRun { + if let suspendedAt { + let duration = Date.timeIntervalSinceReferenceDate - suspendedAt + ScreenSyncCoordinator.shared.shiftTimeline(by: duration) + for window in windows { + (window.contentView as? MatrixScreenSaverView)?.resumeWallpaperAnimation(after: duration) + } + self.suspendedAt = nil + } + } else if suspendedAt == nil && !windows.isEmpty { + suspendedAt = Date.timeIntervalSinceReferenceDate + for window in windows { + (window.contentView as? MatrixScreenSaverView)?.pauseWallpaperAnimation() + } + } } /// Changes desktop ownership without coupling it to transient animation windows. @@ -399,7 +429,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 defaults.bool(forKey: Self.syncPreferenceKey), playback.isRequested, !playback.isPaused, currentDisplayLayout.contains(where: { $0.id == displayID }) else { return } let starting = !playback.isServingScreenSaver if starting { diff --git a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift index 8cc5f1e..07b95e9 100644 --- a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift +++ b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift @@ -19,6 +19,9 @@ struct WallpaperPlaybackState { /// Whether the user wants wallpaper playback, even if it cannot run right now. var isRequested = true + /// Manual pause freezes the scene while leaving its desktop surfaces visible. + var isPaused = false + /// A recent matching saver request lets the wallpaper own animation while its desktop is hidden. var isServingScreenSaver = false @@ -27,7 +30,7 @@ struct WallpaperPlaybackState { /// Whether animation may run after combining user intent and system state. var shouldRun: Bool { - isRequested && suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) } + isRequested && !isPaused && suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) } } /// Desktop visibility remains separate from rendering for the lock-screen consumer. diff --git a/Tests/Homebrew/cask_lifecycle_test.rb b/Tests/Homebrew/cask_lifecycle_test.rb index 6d5b3bd..c2f9b9b 100644 --- a/Tests/Homebrew/cask_lifecycle_test.rb +++ b/Tests/Homebrew/cask_lifecycle_test.rb @@ -100,6 +100,7 @@ def test_quarantine_failure_and_missing_source_abort def test_no_preference_cleanup_artifact dsl = definition assert_equal ["MatrixWallpaper.app", "MatrixScreenSaver.saver"], dsl.artifacts.map { |a| a.source.basename.to_s } + assert_equal ["Matrix Wallpaper.app", "MatrixScreenSaver.saver"], dsl.artifacts.map { |a| a.target.basename.to_s } assert_equal({quit: "dev.patsch.MatrixWallpaper", login_item: "Matrix Wallpaper"}, dsl.uninstall_directives) source = File.read(File.expand_path("../../Homebrew/Casks/matrix-screen-saver.rb", __dir__)) refute_match(/\b(zap|defaults|unregister|launchctl)\b/, source) @@ -111,9 +112,9 @@ def test_app_conflict_uses_custom_appdir_and_never_prints_success dsl = definition(occupied: true, artifact_index: 0) assert_raises(RuntimeError) { dsl.install } assert_equal "manual", (dsl.artifacts.first.target/"executable").read - guidance = dsl.commands.find { |_, args| args.any? { |arg| arg.include?("Existing app at #{@root}/custom apps/MatrixWallpaper.app") } } + guidance = dsl.commands.find { |_, args| args.any? { |arg| arg.include?("Existing app at #{@root}/custom apps/Matrix Wallpaper.app") } } assert guidance, "missing custom appdir conflict guidance" - assert_equal [{path: "MatrixWallpaper.app", base: :appdir}], guidance[2][:writable_paths] + assert_equal [{path: "Matrix Wallpaper.app", base: :appdir}], guidance[2][:writable_paths] refute_includes dsl.messages, COFFEE assert_equal "saved options\x00", @prefs.read dsl.install(force: true) diff --git a/Tests/Homebrew/documentation_test.py b/Tests/Homebrew/documentation_test.py index 155197d..738686a 100644 --- a/Tests/Homebrew/documentation_test.py +++ b/Tests/Homebrew/documentation_test.py @@ -11,7 +11,7 @@ def test_user_lifecycle_and_security(self): self.assertIn("brew trust --cask patrickschaper/tap/matrix-screen-saver", quick) self.assertIn("brew install --cask patrickschaper/tap/matrix-screen-saver", quick) self.assertLessEqual(len(quick.strip().splitlines()), 7) - self.assertIn("open **MatrixWallpaper** in Applications", quick) + self.assertIn("open **Matrix Wallpaper** in Applications", quick) self.assertIn("MatrixScreenSaver → Options…", quick) self.assertNotIn("## Homebrew install", text) self.assertNotIn("## Homebrew lifecycle", text) diff --git a/Tests/Homebrew/fixtures/legacy-wallpaper.rb b/Tests/Homebrew/fixtures/legacy-wallpaper.rb new file mode 100644 index 0000000..6d6766a --- /dev/null +++ b/Tests/Homebrew/fixtures/legacy-wallpaper.rb @@ -0,0 +1,62 @@ +cask "matrix-screen-saver" do + version "0.2.0" + sha256 "7a60c8ec2855a2df4cb008da7f79ea9e8526385897e3048bb53bb42421e07acd" + + url "https://github.com/patrickschaper/matrixScreenSaver/releases/download/#{version}/#{version}.zip" + name "MatrixScreenSaver" + desc "Native terminal-style Matrix screen saver and wallpaper" + homepage "https://github.com/patrickschaper/matrixScreenSaver" + + depends_on arch: :arm64 + depends_on macos: :sequoia + + app "MatrixWallpaper.app" + screen_saver "MatrixScreenSaver.saver" + + preflight_steps do + if_path_exists "MatrixScreenSaver.saver", base: :screen_saverdir do + # Homebrew also grants read access here when the native target is under HOME. + run "/bin/echo", + args: ["Existing screen saver in Homebrew's configured screen-saver directory. " \ + "To replace it while retaining options, run: " \ + "brew install --cask --force patrickschaper/tap/matrix-screen-saver"], + print_stdout: true, + writable_paths: [{ path: "MatrixScreenSaver.saver", base: :screen_saverdir }] + end + + if_path_exists "MatrixWallpaper.app", base: :appdir do + run "/bin/echo", + args: ["Existing app at {{appdir}}/MatrixWallpaper.app. " \ + "To replace it while retaining options, run: " \ + "brew install --cask --force patrickschaper/tap/matrix-screen-saver"], + print_stdout: true, + writable_paths: [{ path: "MatrixWallpaper.app", base: :appdir }] + end + + run "/bin/echo", + args: ["Removing staged quarantine bypasses Gatekeeper quarantine checks; " \ + "this does not make either bundle notarized or Apple-approved."], + print_stdout: true + run "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "{{staged_path}}/MatrixScreenSaver.saver"], + writable_paths: ["{{staged_path}}/MatrixScreenSaver.saver"] + run "/usr/bin/xattr", + args: ["-dr", "com.apple.quarantine", "{{staged_path}}/MatrixWallpaper.app"], + writable_paths: ["{{staged_path}}/MatrixWallpaper.app"] + end + + postflight_steps do + run "/bin/echo", + args: ["There is no spoon. There is coffee: https://www.buymeacoffee.com/yesman82"], + print_stdout: true + run "/bin/echo", + args: ["Open System Settings > Wallpaper > Screen Saver and select MatrixScreenSaver."], + print_stdout: true + run "/bin/echo", + args: ["Open {{appdir}}/MatrixWallpaper.app to start Matrix Wallpaper. " \ + "Launch at Login is optional in its Matrix menu."], + print_stdout: true + end + + uninstall quit: "dev.patsch.MatrixWallpaper", login_item: "Matrix Wallpaper" +end diff --git a/Tests/Homebrew/fixtures/release_fixture.py b/Tests/Homebrew/fixtures/release_fixture.py index 6e20d76..869b7aa 100644 --- a/Tests/Homebrew/fixtures/release_fixture.py +++ b/Tests/Homebrew/fixtures/release_fixture.py @@ -5,7 +5,7 @@ def write_archive(path, version="1.2.3", cpu=0x0100000C, minimum=15, root="MatrixScreenSaver.saver", - *, wallpaper=True, wallpaper_minimum=15): + *, wallpaper=True, wallpaper_minimum=15, wallpaper_root="Matrix Wallpaper.app"): command = struct.pack("<6I", 0x32, 24, 1, minimum << 16, minimum << 16, 0) binary = struct.pack("<8I", 0xFEEDFACF, cpu, 0, 8, 1, len(command), 0, 0) + command info = plistlib.dumps({"CFBundleIdentifier": "dev.patsch.MatrixScreenSaver", @@ -21,7 +21,7 @@ def write_archive(path, version="1.2.3", cpu=0x0100000C, minimum=15, root="Matri info = plistlib.dumps({"CFBundleIdentifier": "dev.patsch.MatrixWallpaper", "CFBundleShortVersionString": version, "CFBundleVersion": version, "CFBundleExecutable": "MatrixWallpaper", - "CFBundlePackageType": "APPL", "CFBundleName": "MatrixWallpaper"}) - for name, data in [("MatrixWallpaper.app/Contents/Info.plist", info), - ("MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper", binary)]: + "CFBundlePackageType": "APPL", "CFBundleName": "Matrix Wallpaper"}) + for name, data in [(f"{wallpaper_root}/Contents/Info.plist", info), + (f"{wallpaper_root}/Contents/MacOS/MatrixWallpaper", binary)]: archive.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data) diff --git a/Tests/Homebrew/integration_lifecycle.sh b/Tests/Homebrew/integration_lifecycle.sh index 11f8d94..a710df1 100644 --- a/Tests/Homebrew/integration_lifecycle.sh +++ b/Tests/Homebrew/integration_lifecycle.sh @@ -19,12 +19,23 @@ export HOMEBREW_DEVELOPER=1 mkdir -p "$HOMEBREW_CACHE" "$HOMEBREW_LOGS" "$HOMEBREW_TEMP" PYTHONDONTWRITEBYTECODE=1 python3 - "$root" <<'PY' from pathlib import Path +import hashlib +import importlib.util import sys sys.path.insert(0, "Tests/Homebrew") from fixtures.release_fixture import write_archive root = Path(sys.argv[1]) +write_archive(root / "1.2.1.zip", version="1.2.1", wallpaper_root="MatrixWallpaper.app") write_archive(root / "1.2.2.zip", version="1.2.2", wallpaper=False) +spec = importlib.util.spec_from_file_location("renderer", "Scripts/render-homebrew-release.py") +renderer = importlib.util.module_from_spec(spec) +spec.loader.exec_module(renderer) for version in ("1.2.3", "1.2.4"): - write_archive(root / f"{version}.zip", version=version) + archive = root / f"{version}.zip" + write_archive(archive, version=version, + wallpaper_root="MatrixWallpaper.app" if version == "1.2.3" else "Matrix Wallpaper.app") + renderer.render(archive, version, renderer.asset_url(version), + "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest(), + "native lifecycle fixture", root / f"handoff-{version}") PY brew ruby Tests/Homebrew/support/integration_driver.rb "$root" diff --git a/Tests/Homebrew/release_metadata_test.py b/Tests/Homebrew/release_metadata_test.py index 5faeb24..46d5242 100644 --- a/Tests/Homebrew/release_metadata_test.py +++ b/Tests/Homebrew/release_metadata_test.py @@ -48,10 +48,30 @@ def test_pinned_handoff_and_deterministic_rerun(self): cask = (self.root / "out/Casks/matrix-screen-saver.rb").read_text() self.assertIn('version "1.2.3"', cask) self.assertIn(manifest["sha256"], cask) + self.assertIn('app "Matrix Wallpaper.app", target: "Matrix Wallpaper.app"', cask) + self.assertIn('"{{staged_path}}/Matrix Wallpaper.app"', cask) + self.assertNotIn("MatrixWallpaper.app", cask) self.assertNotIn("PLACEHOLDER", cask) self.assertEqual(0, self.run_render().returncode) self.assertEqual(cask, (self.root / "out/Casks/matrix-screen-saver.rb").read_text()) + def test_immutable_old_archive_installs_with_new_name(self): + write_archive(self.archive, wallpaper_root="MatrixWallpaper.app") + result = self.run_render() + self.assertEqual(0, result.returncode, result.stderr) + cask = (self.root / "out/Casks/matrix-screen-saver.rb").read_text() + self.assertIn('app "MatrixWallpaper.app", target: "Matrix Wallpaper.app"', cask) + self.assertIn('"{{staged_path}}/MatrixWallpaper.app"', cask) + self.assertIn("Open {{appdir}}/Matrix Wallpaper.app", cask) + + def test_rejects_archives_containing_both_app_names(self): + with zipfile.ZipFile(self.archive, "a") as archive: + archive.writestr("MatrixWallpaper.app/Contents/Info.plist", b"ambiguous") + result = self.run_render() + self.assertNotEqual(0, result.returncode) + self.assertIn("exactly one wallpaper app", result.stderr) + self.assertFalse((self.root / "out").exists()) + def test_rejects_invalid_inputs_without_handoff(self): cases = [dict(version="../oops"), dict(digest="sha256:" + "0" * 64), dict(url="https://github.com/patrickschaper/matrixScreenSaver/releases/latest/download/1.2.3.zip")] @@ -87,7 +107,7 @@ def test_rejects_missing_bundles(self): write_archive(self.archive) with zipfile.ZipFile(self.archive) as archive: app = [(entry, archive.read(entry)) for entry in archive.infolist() - if entry.filename.startswith("MatrixWallpaper.app/")] + if entry.filename.startswith("Matrix Wallpaper.app/")] with zipfile.ZipFile(self.archive, "w") as archive: for entry, data in app: archive.writestr(entry, data) @@ -95,8 +115,8 @@ def test_rejects_missing_bundles(self): self.assertFalse((self.root / "out").exists()) def test_rejects_invalid_wallpaper_metadata_and_binary(self): - plist_path = "MatrixWallpaper.app/Contents/Info.plist" - binary_path = "MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper" + plist_path = "Matrix Wallpaper.app/Contents/Info.plist" + binary_path = "Matrix Wallpaper.app/Contents/MacOS/MatrixWallpaper" cases = [(plist_path, key, value) for key, value in [ ("CFBundleIdentifier", "dev.patsch.Other"), ("CFBundleShortVersionString", "9.9.9"), ("CFBundleVersion", "9.9.9"), @@ -129,7 +149,7 @@ def test_rejects_oversized_archive_before_reading_entries(self): spec = importlib.util.spec_from_file_location("render_release", SCRIPT) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) - entry = zipfile.ZipInfo("MatrixWallpaper.app/large") + entry = zipfile.ZipInfo("Matrix Wallpaper.app/large") entry.file_size = 100_000_001 digest = "sha256:" + hashlib.sha256(self.archive.read_bytes()).hexdigest() with patch.object(module.zipfile.ZipFile, "infolist", return_value=[entry]): @@ -140,7 +160,7 @@ def test_rejects_oversized_archive_before_reading_entries(self): def test_archive_guards(self): for name, mode in [("../escape", 0), ("/absolute", 0), ("bad\\path", 0), - ("Unexpected/file", 0), ("MatrixWallpaper.app/link", 0o120777), + ("Unexpected/file", 0), ("Matrix Wallpaper.app/link", 0o120777), ("MatrixScreenSaver.saver/Contents/Info.plist", 0)]: with self.subTest(name=name): write_archive(self.archive) @@ -159,11 +179,11 @@ def test_release_workflow_builds_and_stages_both_bundles(self): for job in (prepare, publish): self.assertIn("./build.sh\n", job) self.assertIn("./build-wallpaper.sh\n", job) - for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"): + for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"): self.assertIn(f'codesign --verify --deep --strict "build/{bundle}"', job) self.assertLess(prepare.index('printf \'%s\\n\' "${{ steps.version.outputs.next }}" > VERSION'), prepare.index("./build.sh\n")) - self.assertIn('ditto "build/MatrixWallpaper.app" "${release_dir}/MatrixWallpaper.app"', publish) + self.assertIn('ditto "build/Matrix Wallpaper.app" "${release_dir}/Matrix Wallpaper.app"', publish) def test_release_uses_highest_version_tag_not_nearest(self): repository = self.root / "repository" diff --git a/Tests/Homebrew/support/fake_cask_dsl.rb b/Tests/Homebrew/support/fake_cask_dsl.rb index 233feab..00a4b62 100644 --- a/Tests/Homebrew/support/fake_cask_dsl.rb +++ b/Tests/Homebrew/support/fake_cask_dsl.rb @@ -29,8 +29,8 @@ def screen_saver(name) @artifacts << Artifact.new(@root/"stage"/name, @root/"custom savers"/name) end - def app(name) - @artifacts << Artifact.new(@root/"stage"/name, @root/"custom apps"/name) + def app(name, target: name) + @artifacts << Artifact.new(@root/"stage"/name, @root/"custom apps"/target) end def uninstall(**directives) = @uninstall_directives = directives diff --git a/Tests/Homebrew/support/integration_driver.rb b/Tests/Homebrew/support/integration_driver.rb index 38ad989..1b8290a 100644 --- a/Tests/Homebrew/support/integration_driver.rb +++ b/Tests/Homebrew/support/integration_driver.rb @@ -19,7 +19,8 @@ (TAP.path/"Casks").mkpath CONFIG = Cask::Config.new(explicit: Cask::Config::DEFAULT_DIRS.keys.to_h { |key| [key, ROOT/key.to_s] }) TARGET = CONFIG.screen_saverdir/"MatrixScreenSaver.saver" -APP_TARGET = CONFIG.appdir/"MatrixWallpaper.app" +APP_TARGET = CONFIG.appdir/"Matrix Wallpaper.app" +LEGACY_APP_TARGET = CONFIG.appdir/"MatrixWallpaper.app" TARGETS = [TARGET, APP_TARGET].freeze PREFS = ROOT/"preferences-sentinel" PREFS.write("saved options\x00") @@ -77,8 +78,13 @@ def capture $stdout, $stderr = old_out, old_err end -def definition(version, legacy: false) - content = Pathname(legacy ? "Tests/Homebrew/fixtures/legacy-screen-saver.rb" : "Homebrew/Casks/matrix-screen-saver.rb").read +def definition(version, legacy: nil) + source = case legacy + when :saver then Pathname("Tests/Homebrew/fixtures/legacy-screen-saver.rb") + when :wallpaper then Pathname("Tests/Homebrew/fixtures/legacy-wallpaper.rb") + else ROOT/"handoff-#{version}/Casks/matrix-screen-saver.rb" + end + content = source.read content.sub!('version "0.2.0"', "version \"#{version}\"") content.sub!(/sha256 "[0-9a-f]+"/, "sha256 \"#{Digest::SHA256.file(ROOT/"#{version}.zip").hexdigest}\"") content.sub!(/^ url .+$/, " url \"file://#{ROOT}/#{version}.zip\"") @@ -144,7 +150,7 @@ def uninstall(cask) uninstall(fresh) OSASCRIPT_CALLS.clear -legacy = definition("1.2.2", legacy: true) +legacy = definition("1.2.2", legacy: :saver) capture { Cask::Installer.new(legacy).install } verify_target("1.2.2", targets: [TARGET]) check(!APP_TARGET.exist?, "Legacy fixture installed an app") @@ -205,6 +211,43 @@ def uninstall(cask) uninstall(reinstall) puts "PASS AE5 native uninstall and managed reinstall preference retention" +# The receipt owns the old filename; native upgrade must restore it on failure. +old_name = definition("1.2.1", legacy: :wallpaper) +capture { Cask::Installer.new(old_name).install } +verify_target("1.2.1", targets: [TARGET, LEGACY_APP_TARGET]) +APP_TARGET.mkpath +(APP_TARGET/"manual-executable").write("manual sentinel") +renamed = definition("1.2.4") +failed = false +output = capture do + begin + Cask::Upgrade.upgrade_cask(old_name, renamed, binaries: true, force: false, + require_sha: true, quit: false, skip_cask_deps: false, verbose: false, + download_queue: Homebrew::DownloadQueue.default, + new_cask_installer: Cask::Installer.new(renamed, upgrade: true)) + rescue Cask::CaskError + failed = true + end +end +check(failed, "Rename upgrade unexpectedly replaced an unrelated app") +check((APP_TARGET/"manual-executable").read == "manual sentinel", "Rename upgrade changed unrelated app") +verify_target("1.2.1", targets: [TARGET, LEGACY_APP_TARGET]) +check(output.include?("Reverting upgrade"), "Rename upgrade did not report rollback") +FileUtils.remove_entry(APP_TARGET) +renamed = definition("1.2.4") +OSASCRIPT_CALLS.clear +capture do + Cask::Upgrade.upgrade_cask(old_name, renamed, binaries: true, force: false, + require_sha: true, quit: false, skip_cask_deps: false, verbose: false, + download_queue: Homebrew::DownloadQueue.default, + new_cask_installer: Cask::Installer.new(renamed, upgrade: true)) +end +verify_target("1.2.4") +check(!LEGACY_APP_TARGET.exist?, "Rename upgrade left the old app filename") +check(OSASCRIPT_CALLS.none? { |args| args.join.include?("delete every login item") }, "Rename upgrade removed login items") +uninstall(renamed) +puts "PASS native old-name app upgrade, conflict rollback, and preference retention" + # AE2/AE3: existing manual bundle survives native rejection, then explicit force replaces. TARGETS.each do |target| target.mkpath diff --git a/Tests/Homebrew/tap_update_test.py b/Tests/Homebrew/tap_update_test.py index 57b6097..2b8e919 100644 --- a/Tests/Homebrew/tap_update_test.py +++ b/Tests/Homebrew/tap_update_test.py @@ -8,11 +8,12 @@ SCRIPT = ROOT / "Scripts/update-homebrew-tap.py" TEMPLATE = (ROOT / "Homebrew/Casks/matrix-screen-saver.rb").read_text() LEGACY = (ROOT / "Tests/Homebrew/fixtures/legacy-screen-saver.rb").read_text() +LEGACY_WALLPAPER = (ROOT / "Tests/Homebrew/fixtures/legacy-wallpaper.rb").read_text() -def cask(version="1.2.3", digest="a" * 64, floor="sequoia"): +def cask(version="1.2.3", digest="a" * 64, floor="sequoia", app="Matrix Wallpaper.app"): import re - text = re.sub(r'^ version "[^"]+"$', f' version "{version}"', TEMPLATE, flags=re.M) + text = re.sub(r'^ version "[^"]+"$', f' version "{version}"', TEMPLATE.replace("MatrixWallpaper.app", app), flags=re.M) text = re.sub(r'^ sha256 "[^"]+"$', f' sha256 "{digest}"', text, flags=re.M) return re.sub(r'^ depends_on macos: :\w+$', f' depends_on macos: :{floor}', text, flags=re.M) @@ -42,7 +43,7 @@ def setUp(self): spec.loader.exec_module(self.module) def test_known_saver_only_cask_migrates_without_allowing_unrelated_changes(self): - self.assertIn('app "MatrixWallpaper.app"', cask()) + self.assertIn('app "Matrix Wallpaper.app", target: "Matrix Wallpaper.app"', cask()) api = FakeAPI(LEGACY) self.module.update(api, "owner/homebrew-test", "1.2.3", cask()) self.assertEqual(cask(), api.content) @@ -54,6 +55,22 @@ def test_known_saver_only_cask_migrates_without_allowing_unrelated_changes(self) self.module.update(api, "owner/homebrew-test", "1.2.3", cask()) self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + def test_old_name_dual_bundle_cask_migrates_without_allowing_unrelated_changes(self): + for app in ("MatrixWallpaper.app", "Matrix Wallpaper.app"): + with self.subTest(app=app): + api = FakeAPI(LEGACY_WALLPAPER) + self.module.update(api, "owner/homebrew-test", "1.2.3", cask(app=app)) + self.assertEqual(cask(app=app), api.content) + api = FakeAPI(LEGACY_WALLPAPER + "# unreviewed change\n") + with self.assertRaises(ValueError): + self.module.update(api, "owner/homebrew-test", "1.2.3", cask(app=app)) + self.assertTrue(all(method == "GET" for method, _, _ in api.calls)) + + def test_old_archive_source_upgrades_to_renamed_archive_source(self): + api = FakeAPI(cask("1.2.3", app="MatrixWallpaper.app")) + self.module.update(api, "owner/homebrew-test", "1.2.4", cask("1.2.4")) + self.assertEqual(cask("1.2.4"), api.content) + def test_legacy_migration_never_rewrites_same_or_newer_version(self): for version in ("0.1.0", "0.2.0"): api = FakeAPI(LEGACY) diff --git a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift index f91497e..d55eaa6 100644 --- a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift +++ b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift @@ -4,6 +4,20 @@ import Foundation func wallpaperPlaybackStateTests() { var state = WallpaperPlaybackState() ok(state.shouldRun, "WallpaperPlaybackState: launches with playback requested") + state.isPaused = true + ok(!state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: manual pause keeps the desktop visible") + state.setSuspended(.sleeping, true) + ok(!state.shouldRun && !state.shouldShowDesktop, "WallpaperPlaybackState: sleep hides a paused desktop") + state.setSuspended(.sleeping, false) + ok(!state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: wake preserves manual pause") + state.isServingScreenSaver = true + state.setSuspended(.locked, true) + ok(!state.shouldRun && !state.shouldShowDesktop, "WallpaperPlaybackState: saver sharing cannot override manual pause") + state.isServingScreenSaver = false + state.isPaused = false + ok(!state.shouldRun, "WallpaperPlaybackState: manual resume cannot override lock") + state.setSuspended(.locked, false) + ok(state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: resume runs after system suspension clears") state.setSuspended(.locked, true) state.setSuspended(.locked, true) state.setSuspended(.sleeping, true) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 444aafb..51dfea2 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -15,7 +15,22 @@ func wallpaperAppTests() { host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) defer { host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } let status: NSStatusItem? = stored("statusItem", in: host) - check(status?.menu?.items.first?.title == "Stop", "launch installs playback controls before returning") + 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 ?? [] + 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 && + syncIndex + 1 < menuItems.count && menuItems[syncIndex + 1].isSeparatorItem, + "Sync with Screen Saver sits below Launch at Login between dividers") + } else { + check(false, "menu contains login and screen saver sync controls") + } check(stored("windows", in: host, as: [DesktopWindow].self)?.isEmpty == true && ScreenSyncCoordinator.shared.registeredCount == 0 && desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg", @@ -28,6 +43,29 @@ func wallpaperAppTests() { let originalIDs = Set(originalWindows.map(ObjectIdentifier.init)) 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") + 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 { + let frozen = pixels(of: source) + wait(0.3) + check(frozen != nil && pixels(of: source) == frozen, "paused wallpaper retains the same pixels") + } + workspace.post(name: NSWorkspace.willSleepNotification, object: nil) + check(desktopWindowCount() == 0, "sleep hides an already manually paused wallpaper") + workspace.post(name: NSWorkspace.didWakeNotification, object: nil) + check(originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false }, + "wake reveals the frozen wallpaper without clearing manual pause") + workspace.post(name: NSWorkspace.willSleepNotification, object: nil) + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + 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 }, + "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") check(desktopWindowCount() == 0 && ScreenSyncCoordinator.shared.registeredCount == originalWindows.count, @@ -67,6 +105,20 @@ func wallpaperAppTests() { let next = reader?.frame(displayID: displayID, pointSize: source.bounds.size) check(next != nil && next!.sequence > (first?.sequence ?? 0), "hidden wallpaper publishes advancing frames for the saver") + let pausedURL = source.shareWallpaperFrames()! + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check(!FileManager.default.fileExists(atPath: pausedURL.path) && !source.isAnimating && desktopWindowCount() == 0, + "Pause ends active saver sharing without revealing a locked desktop") + check(eventually(5) { + let frame = reader?.frame(displayID: displayID, pointSize: source.bounds.size) + return frame == nil + }, "paused producer lets the saver fall back instead of offering stale frames") + check(!source.isAnimating, "repeated saver requests cannot override manual pause") + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check(eventually(5) { + let frame = reader?.frame(displayID: displayID, pointSize: source.bounds.size) + return frame != nil && source.isAnimating + }, "Resume reconnects the saver to the preserved wallpaper scene") let sharedURL = source.shareWallpaperFrames()! NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil) check(!FileManager.default.fileExists(atPath: sharedURL.path) && !source.isAnimating, @@ -128,9 +180,13 @@ func wallpaperAppTests() { check(source.isAnimating && Set(NSApp.windows.filter { $0 is DesktopWindow && $0.isVisible }.map(ObjectIdentifier.init)) == originalIDs, "saver exit reveals the same wallpaper views without replaying the intro") } + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) 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") + 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") NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil) @@ -160,6 +216,9 @@ func wallpaperAppTests() { NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil) check(desktopWindowCount() == NSScreen.screens.count, "Start recreates wallpaper on all displays") + check(pauseItem?.isEnabled == true && (stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true + }, "Start runs normally after stopping a paused session") check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "Start reapplies black desktops") NotificationCenter.default.post(name: NSApplication.didChangeScreenParametersNotification, object: nil) check(desktopWindowCount() == NSScreen.screens.count && ScreenSyncCoordinator.shared.registeredCount == NSScreen.screens.count, @@ -249,6 +308,21 @@ private func wallpaperDeferredLaunchTests() { } catch { check(false, "deferred recovery fixture: \(error)") } + do { + let desktop = DesktopWallpaperFixture() + let host = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller()) + host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + defer { host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) } + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check(eventually { desktopWindowCount() == NSScreen.screens.count }, "Pause during queued launch still creates desktop surfaces") + check((stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false + }, "queued startup preserves an immediate manual pause") + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check((stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy { + ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true + }, "Resume starts the session paused during launch") + } for action in ["Stop", "Quit", "Stop then Start"] { let desktop = DesktopWallpaperFixture() var writes = 0 diff --git a/Tests/Signing/signing_test.py b/Tests/Signing/signing_test.py index f00571a..e4889ab 100644 --- a/Tests/Signing/signing_test.py +++ b/Tests/Signing/signing_test.py @@ -77,9 +77,12 @@ def prepare_notary(self, status="Accepted"): response = self.directory / "response.plist" response.write_bytes(plistlib.dumps({"status": status, "id": "test-id"})) self.env["NOTARY_RESPONSE"] = str(response) - self.stub("ditto", 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n') + self.stub("ditto", '''printf '%s\\n' "$*" >> "$COMMAND_LOG" +if [[ "$1" == -c ]]; then [[ "$#" == 5 ]]; else [[ "$#" == 2 ]]; fi +''') self.stub("xcrun", '''printf '%s\\n' "$*" >> "$COMMAND_LOG" if [[ "$1 $2" == 'notarytool submit' ]]; then cat "$NOTARY_RESPONSE"; fi +if [[ "$1" == stapler ]]; then [[ "$#" == 3 ]]; fi if [[ "$1 $2" == 'stapler staple' ]]; then exit "${STAPLE_FAILURE:-0}"; fi ''') @@ -89,7 +92,7 @@ def test_accepted_submission_staples_and_validates_both_bundles(self): self.assertEqual(0, result.returncode, result.stderr) commands = self.log.read_text() self.assertIn("--keychain-profile test-profile --wait", commands) - for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"): + for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"): self.assertIn(f"stapler staple {ROOT}/build/{bundle}", commands) self.assertIn(f"stapler validate {ROOT}/build/{bundle}", commands) @@ -112,7 +115,7 @@ def test_release_notarizes_before_staging_and_cleans_up_on_failure(self): self.assertNotIn("secrets.DEVELOPER_ID", prepare) self.assertIn("SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}", publish) self.assertIn("APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}", publish) - for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"): + for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"): sign_command = f'bash Scripts/sign-bundle.sh "build/{bundle}"' self.assertIn(sign_command, publish) self.assertLess(publish.index("./build-wallpaper.sh"), publish.index(sign_command)) diff --git a/build-wallpaper.sh b/build-wallpaper.sh index 9d5ef1c..24f6489 100755 --- a/build-wallpaper.sh +++ b/build-wallpaper.sh @@ -4,7 +4,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" SOURCE_DIR="$ROOT_DIR/Sources/MatrixScreenSaver" WALLPAPER_DIR="$ROOT_DIR/Sources/MatrixWallpaper" -BUNDLE_DIR="$ROOT_DIR/build/MatrixWallpaper.app" +BUNDLE_DIR="$ROOT_DIR/build/Matrix Wallpaper.app" if [[ ! -s "$ROOT_DIR/VERSION" ]]; then echo "VERSION file is missing or empty." >&2 diff --git a/install-wallpaper.sh b/install-wallpaper.sh index a65b3c9..b974ebe 100755 --- a/install-wallpaper.sh +++ b/install-wallpaper.sh @@ -2,9 +2,10 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" -SOURCE_BUNDLE="${1:-$ROOT_DIR/build/MatrixWallpaper.app}" +SOURCE_BUNDLE="${1:-$ROOT_DIR/build/Matrix Wallpaper.app}" INSTALL_DIR="$HOME/Applications" -INSTALL_BUNDLE="$INSTALL_DIR/MatrixWallpaper.app" +INSTALL_BUNDLE="$INSTALL_DIR/Matrix Wallpaper.app" +LEGACY_BUNDLE="$INSTALL_DIR/MatrixWallpaper.app" if ! osascript -l JavaScript <<'JXA' ObjC.import('AppKit'); @@ -51,6 +52,11 @@ if ! verify_bundle "$SOURCE_BUNDLE"; then exit 1 fi +if [[ -d "$INSTALL_BUNDLE" ]] && ! verify_bundle "$INSTALL_BUNDLE"; then + echo "Refusing to replace an unrecognized app: $INSTALL_BUNDLE" >&2 + exit 1 +fi + mkdir -p "$INSTALL_DIR" STAGING="$(mktemp -d "$INSTALL_DIR/.MatrixWallpaper-install.XXXXXX")" @@ -62,6 +68,12 @@ cleanup() { return 1 fi fi + if [[ "$result" -ne 0 && -d "$STAGING/legacy.app" && ! -e "$LEGACY_BUNDLE" ]]; then + if ! mv "$STAGING/legacy.app" "$LEGACY_BUNDLE"; then + echo "Restore failed. Your previous app is preserved at $STAGING/legacy.app" >&2 + return 1 + fi + fi rm -rf "$STAGING" return "$result" } @@ -69,10 +81,10 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM -ditto "$SOURCE_BUNDLE" "$STAGING/MatrixWallpaper.app" -verify_bundle "$STAGING/MatrixWallpaper.app" +ditto "$SOURCE_BUNDLE" "$STAGING/Matrix Wallpaper.app" +verify_bundle "$STAGING/Matrix Wallpaper.app" SOURCE_HASH="$(shasum -a 256 "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')" -COPIED_HASH="$(shasum -a 256 "$STAGING/MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')" +COPIED_HASH="$(shasum -a 256 "$STAGING/Matrix Wallpaper.app/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')" if [[ "$SOURCE_HASH" != "$COPIED_HASH" ]]; then echo "Copied executable does not match the source; installation cancelled." >&2 exit 1 @@ -81,6 +93,10 @@ fi if [[ -d "$INSTALL_BUNDLE" ]]; then mv "$INSTALL_BUNDLE" "$STAGING/previous.app" fi -mv "$STAGING/MatrixWallpaper.app" "$INSTALL_BUNDLE" +# Keep the old bundle for rollback until promotion succeeds; ignore unrelated bundles and symlinks. +if [[ ! -L "$LEGACY_BUNDLE" && -d "$LEGACY_BUNDLE" ]] && verify_bundle "$LEGACY_BUNDLE" >/dev/null 2>&1; then + mv "$LEGACY_BUNDLE" "$STAGING/legacy.app" +fi +mv "$STAGING/Matrix Wallpaper.app" "$INSTALL_BUNDLE" echo "Installed $INSTALL_BUNDLE" echo "Double-click it in Finder, or run: open \"$INSTALL_BUNDLE\"" diff --git a/wallpaper-install-tests.sh b/wallpaper-install-tests.sh index cae7980..dadfb09 100755 --- a/wallpaper-install-tests.sh +++ b/wallpaper-install-tests.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")" && pwd)" -SOURCE_BUNDLE="${1:-$ROOT_DIR/build/MatrixWallpaper.app}" +SOURCE_BUNDLE="${1:-$ROOT_DIR/build/Matrix Wallpaper.app}" if [[ ! -d "$SOURCE_BUNDLE" ]]; then echo "Build the app with ./build-wallpaper.sh before running installer checks." >&2 exit 1 @@ -10,6 +10,9 @@ fi TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-wallpaper-install-tests.XXXXXX")" trap 'rm -rf "$TEST_DIR"' EXIT export HOME="$TEST_DIR/home" +INSTALL_BUNDLE="$HOME/Applications/Matrix Wallpaper.app" +LEGACY_BUNDLE="$HOME/Applications/MatrixWallpaper.app" +EXPECTED_BUNDLE="$INSTALL_BUNDLE" mkdir -p "$HOME" "$TEST_DIR/bin" export PATH="$TEST_DIR/bin:$PATH" @@ -31,7 +34,7 @@ fi SCRIPT cat > "$TEST_DIR/bin/mv" <<'SCRIPT' #!/bin/bash -if [[ "${FAIL_PROMOTION:-0}" == 1 && "$1" == */.MatrixWallpaper-install.*/MatrixWallpaper.app ]]; then +if [[ "${FAIL_PROMOTION:-0}" == 1 && "$1" == */.MatrixWallpaper-install.*/"Matrix Wallpaper.app" ]]; then exit 1 fi /bin/mv "$@" @@ -44,17 +47,17 @@ expect_failure() { echo "not ok - $1" >&2 exit 1 fi - [[ "$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")" == "$OLD_INODE" ]] - codesign --verify --deep --strict "$HOME/Applications/MatrixWallpaper.app" + [[ "$(stat -f %i "$EXPECTED_BUNDLE")" == "$OLD_INODE" ]] + codesign --verify --deep --strict "$EXPECTED_BUNDLE" echo "ok - $1 preserves the working installation" } install_app [[ -f "$QUIT_LOG" ]] -codesign --verify --deep --strict "$HOME/Applications/MatrixWallpaper.app" -cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$HOME/Applications/MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper" +codesign --verify --deep --strict "$INSTALL_BUNDLE" +cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$INSTALL_BUNDLE/Contents/MacOS/MatrixWallpaper" echo "ok - installs a verified app into user Applications" -OLD_INODE="$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")" +OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")" mkdir -p "$HOME/Library/Preferences" printf 'unchanged preferences' > "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist" export FAIL_COPY=1 @@ -70,7 +73,61 @@ export SIMULATE_QUIT_FAILURE=1 expect_failure "app refusing to quit" unset SIMULATE_QUIT_FAILURE install_app -[[ "$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")" != "$OLD_INODE" ]] +[[ "$(stat -f %i "$INSTALL_BUNDLE")" != "$OLD_INODE" ]] [[ "$(cat "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist")" == 'unchanged preferences' ]] [[ -z "$(find "$HOME/Applications" -maxdepth 1 -name '.MatrixWallpaper-install.*' -print)" ]] echo "ok - reinstall replaces the old bundle and cleans staging" + +# An old-name-only installation must survive failures at each replacement stage. +/bin/mv "$INSTALL_BUNDLE" "$LEGACY_BUNDLE" +EXPECTED_BUNDLE="$LEGACY_BUNDLE" +OLD_INODE="$(stat -f %i "$LEGACY_BUNDLE")" +for failure in FAIL_COPY CORRUPT_COPY FAIL_PROMOTION; do + export "$failure=1" + expect_failure "rename migration $failure" + unset "$failure" + [[ ! -e "$INSTALL_BUNDLE" ]] +done +install_app +[[ ! -e "$LEGACY_BUNDLE" ]] +codesign --verify --deep --strict "$INSTALL_BUNDLE" +cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$INSTALL_BUNDLE/Contents/MacOS/MatrixWallpaper" +echo "ok - migrates the old filename to Matrix Wallpaper.app" + +# Both prior copies are recoverable when a user already has both filenames. +/usr/bin/ditto "$SOURCE_BUNDLE" "$LEGACY_BUNDLE" +LEGACY_INODE="$(stat -f %i "$LEGACY_BUNDLE")" +EXPECTED_BUNDLE="$INSTALL_BUNDLE" +OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")" +export FAIL_PROMOTION=1 +expect_failure "promotion with both filenames" +unset FAIL_PROMOTION +[[ "$(stat -f %i "$LEGACY_BUNDLE")" == "$LEGACY_INODE" ]] +codesign --verify --deep --strict "$LEGACY_BUNDLE" +install_app +[[ ! -e "$LEGACY_BUNDLE" ]] +echo "ok - rolls back both filenames and removes the legacy copy only after success" + +/usr/bin/ditto "$SOURCE_BUNDLE" "$LEGACY_BUNDLE" +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier dev.example.Unrelated' "$LEGACY_BUNDLE/Contents/Info.plist" +codesign --force --deep --sign - "$LEGACY_BUNDLE" +LEGACY_INODE="$(stat -f %i "$LEGACY_BUNDLE")" +install_app +[[ "$(stat -f %i "$LEGACY_BUNDLE")" == "$LEGACY_INODE" ]] +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$LEGACY_BUNDLE/Contents/Info.plist")" == dev.example.Unrelated ]] +codesign --verify --deep --strict "$LEGACY_BUNDLE" +echo "ok - preserves an unrelated signed bundle at the old filename" + +rm -rf "$LEGACY_BUNDLE" +ln -s "$INSTALL_BUNDLE" "$LEGACY_BUNDLE" +install_app +[[ -L "$LEGACY_BUNDLE" && "$(readlink "$LEGACY_BUNDLE")" == "$INSTALL_BUNDLE" ]] +echo "ok - leaves an old-name symlink untouched" + +/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier dev.example.Unrelated' "$INSTALL_BUNDLE/Contents/Info.plist" +codesign --force --deep --sign - "$INSTALL_BUNDLE" +OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")" +expect_failure "unrelated app at the new filename" +[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INSTALL_BUNDLE/Contents/Info.plist")" == dev.example.Unrelated ]] +[[ "$(cat "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist")" == 'unchanged preferences' ]] +[[ -z "$(find "$HOME/Applications" -maxdepth 1 -name '.MatrixWallpaper-install.*' -print)" ]] From 9c052a40d1c76f53a3f8565a46d7927a893359f6 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 13:22:09 +0100 Subject: [PATCH 10/17] docs: require semantic versioning for releases - define major, minor, and patch bumps in agent instructions - keep version changes in the release workflow --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index c41935c..adf3c9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,6 +188,7 @@ feat: add character size options ## Releases +- Use Semantic Versioning (`MAJOR.MINOR.PATCH`): major for breaking changes, minor for backward-compatible features, patch for backward-compatible fixes. Select the corresponding bump in the release workflow; `VERSION` remains the source of truth. - Trigger releases by running the **Release** GitHub Actions workflow (`workflow_dispatch`) — never manually. - The workflow bumps the version, updates `CHANGELOG.md`, and opens a `release/*` PR targeting `main`. - Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release with a zipped `.saver` bundle attached. From 4220c0756cf47ebf553b1a589d442c3a1abeeaa9 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 13:32:20 +0100 Subject: [PATCH 11/17] fix: keep paused wallpaper synced during saver playback - advance the shared scene while the screen saver is active - restore manual pause when the saver exits - cover repeated saver sessions and system suspension --- README.md | 5 ++- Sources/MatrixWallpaper/WallpaperApp.swift | 4 +- .../WallpaperPlaybackState.swift | 5 ++- .../WallpaperPlaybackStateTests.swift | 13 +++++- .../WallpaperAppTests.swift | 42 ++++++++++++------- 5 files changed, 46 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 011c1bd..463900e 100644 --- a/README.md +++ b/README.md @@ -195,8 +195,9 @@ installation detection. This does not change visual options or synchronization b 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. While paused, the wallpaper -declines frame sharing so the screen saver animates independently. **Stop** and +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. diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index 2303be2..079d4d6 100644 --- a/Sources/MatrixWallpaper/WallpaperApp.swift +++ b/Sources/MatrixWallpaper/WallpaperApp.swift @@ -236,8 +236,6 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { guard !isTerminating, playback.isRequested else { return } statusItem?.menu?.cancelTracking() playback.isPaused.toggle() - // A paused producer cannot supply fresh frames; let the saver use its own renderer. - if playback.isPaused { endFrameSharing() } reconcilePlayback() } @@ -429,7 +427,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, !playback.isPaused, + guard defaults.bool(forKey: Self.syncPreferenceKey), playback.isRequested, currentDisplayLayout.contains(where: { $0.id == displayID }) else { return } let starting = !playback.isServingScreenSaver if starting { diff --git a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift index 07b95e9..7147479 100644 --- a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift +++ b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift @@ -19,7 +19,7 @@ struct WallpaperPlaybackState { /// Whether the user wants wallpaper playback, even if it cannot run right now. var isRequested = true - /// Manual pause freezes the scene while leaving its desktop surfaces visible. + /// Freezes desktop playback; a synced saver temporarily advances the same scene without clearing this choice. var isPaused = false /// A recent matching saver request lets the wallpaper own animation while its desktop is hidden. @@ -30,7 +30,8 @@ struct WallpaperPlaybackState { /// Whether animation may run after combining user intent and system state. var shouldRun: Bool { - isRequested && !isPaused && suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) } + isRequested && (!isPaused || isServingScreenSaver) && + suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) } } /// Desktop visibility remains separate from rendering for the lock-screen consumer. diff --git a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift index d55eaa6..89eddeb 100644 --- a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift +++ b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift @@ -12,8 +12,19 @@ func wallpaperPlaybackStateTests() { ok(!state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: wake preserves manual pause") state.isServingScreenSaver = true state.setSuspended(.locked, true) - ok(!state.shouldRun && !state.shouldShowDesktop, "WallpaperPlaybackState: saver sharing cannot override manual pause") + ok(state.shouldRun && state.isPaused && !state.shouldShowDesktop, "WallpaperPlaybackState: saver sharing temporarily advances a manually paused scene") + for reason in [WallpaperPlaybackState.Suspension.sleeping, .displaysSleeping, .sessionInactive] { + state.setSuspended(reason, true) + ok(!state.shouldRun, "WallpaperPlaybackState: paused sharing respects \(reason)") + state.setSuspended(reason, false) + } + state.isRequested = false + ok(!state.shouldRun, "WallpaperPlaybackState: sharing cannot restart a stopped wallpaper") + state.isRequested = true state.isServingScreenSaver = false + state.setSuspended(.locked, false) + ok(!state.shouldRun && state.isPaused && state.shouldShowDesktop, "WallpaperPlaybackState: saver exit restores the paused desktop") + state.setSuspended(.locked, true) state.isPaused = false ok(!state.shouldRun, "WallpaperPlaybackState: manual resume cannot override lock") state.setSuspended(.locked, false) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 51dfea2..8b5303a 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -86,6 +86,8 @@ func wallpaperAppTests() { if let source = originalWindows.first?.contentView as? MatrixScreenSaverView, let screen = originalWindows.first?.screen, let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 { + NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) + check(!source.isAnimating, "wallpaper is paused before saver discovery") var reader: WallpaperFrameReader? = WallpaperFrameReader() _ = reader?.frame(displayID: displayID, pointSize: source.bounds.size) var first: SharedWallpaperFrame.Frame? @@ -94,7 +96,8 @@ func wallpaperAppTests() { wait(0.1) first = reader?.frame(displayID: displayID, pointSize: source.bounds.size) } while first == nil && Date() < deadline - check(first != nil, "host discovery publishes a matching display without restarting its renderer") + check(first != nil && source.isAnimating && pauseItem?.title == "Resume", + "saver discovery advances the paused scene without clearing manual pause") let distributed = DistributedNotificationCenter.default() distributed.postNotificationName(Notification.Name("com.apple.screensaver.willstart"), object: nil, userInfo: nil, deliverImmediately: true) @@ -105,20 +108,10 @@ func wallpaperAppTests() { let next = reader?.frame(displayID: displayID, pointSize: source.bounds.size) check(next != nil && next!.sequence > (first?.sequence ?? 0), "hidden wallpaper publishes advancing frames for the saver") - let pausedURL = source.shareWallpaperFrames()! NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) - check(!FileManager.default.fileExists(atPath: pausedURL.path) && !source.isAnimating && desktopWindowCount() == 0, - "Pause ends active saver sharing without revealing a locked desktop") - check(eventually(5) { - let frame = reader?.frame(displayID: displayID, pointSize: source.bounds.size) - return frame == nil - }, "paused producer lets the saver fall back instead of offering stale frames") - check(!source.isAnimating, "repeated saver requests cannot override manual pause") NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) - check(eventually(5) { - let frame = reader?.frame(displayID: displayID, pointSize: source.bounds.size) - return frame != nil && source.isAnimating - }, "Resume reconnects the saver to the preserved wallpaper scene") + check(source.isAnimating && pauseItem?.title == "Resume" && 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) check(!FileManager.default.fileExists(atPath: sharedURL.path) && !source.isAnimating, @@ -136,6 +129,22 @@ func wallpaperAppTests() { }, "re-enabling sync reconnects through normal frame discovery") check(Set(NSApp.windows.filter { $0 is DesktopWindow }.map(ObjectIdentifier.init)).isSuperset(of: originalIDs), "sync reconnect retains the existing wallpaper views") + reader = nil + distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil, + userInfo: nil, deliverImmediately: true) + wait(0.2) + check(!source.isAnimating && pauseItem?.title == "Resume" && + 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) + wait(0.3) + check(frozen != nil && pixels(of: source) == frozen, "returned wallpaper stays frozen at the advanced scene") + distributed.postNotificationName(Notification.Name("com.apple.screensaver.willstart"), object: nil, + userInfo: nil, deliverImmediately: true) + reader = WallpaperFrameReader() + check(eventually(5) { + reader?.frame(displayID: displayID, pointSize: source.bounds.size) != nil && source.isAnimating + }, "a second saver session resumes sharing while manual pause remains set") workspace.post(name: NSWorkspace.willSleepNotification, object: nil) NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil) NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil) @@ -177,8 +186,11 @@ func wallpaperAppTests() { distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil, userInfo: nil, deliverImmediately: true) wait(0.2) - check(source.isAnimating && Set(NSApp.windows.filter { $0 is DesktopWindow && $0.isVisible }.map(ObjectIdentifier.init)) == originalIDs, - "saver exit reveals the same wallpaper views without replaying the intro") + check(!source.isAnimating && pauseItem?.title == "Resume" && + 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) + check(source.isAnimating, "manual Resume still works after synced saver playback") } NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil) workspace.post(name: NSWorkspace.willSleepNotification, object: nil) From 5a8d7acf3ca47a5218fb7725df1b10f5cc00300a Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 13:32:27 +0100 Subject: [PATCH 12/17] docs: clarify release notes and version bump conventions - omit signing details from changelogs and release notes - classify version bumps as chores --- AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index adf3c9a..d9abccd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,7 @@ class MatrixColumn { ## Git commits - Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...` +- Classify version-number bumps as `chore:`, not `fix:`, in commit messages and PR titles. - Use a short title line. - Add a few very concise bullet points for the meaningful changes. @@ -188,6 +189,7 @@ feat: add character size options ## Releases +- Keep changelog entries and release notes focused on user-facing changes; omit code-signing changes and details. - Use Semantic Versioning (`MAJOR.MINOR.PATCH`): major for breaking changes, minor for backward-compatible features, patch for backward-compatible fixes. Select the corresponding bump in the release workflow; `VERSION` remains the source of truth. - Trigger releases by running the **Release** GitHub Actions workflow (`workflow_dispatch`) — never manually. - The workflow bumps the version, updates `CHANGELOG.md`, and opens a `release/*` PR targeting `main`. From e09c5b17701e8650f046f295b93b87897a7d2002 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 13:50:19 +0100 Subject: [PATCH 13/17] test: stabilize intro redraw timing - pin the typing seed to avoid random pauses between idle frames --- Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index e2636b7..bf0b952 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -288,6 +288,8 @@ private func introDirtyRowTests() { renderer.updateConfiguration(configuration) renderer.resize(to: TerminalSize(columns: 40, rows: 12)) renderer.overrideSceneStartTime = Date.timeIntervalSinceReferenceDate + // This seed finishes typing X before 0.1s, without a random hesitation between the idle frames. + renderer.overrideSceneContentStartTime = 1000 renderer.start() defer { renderer.stop() } renderer.shiftTimeline(by: -0.1) From 9a2ddaf4974f3b4043b2690bed0095aebeee7f1c Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 14:01:01 +0100 Subject: [PATCH 14/17] test: allow scrolling fixture to finish command sequence - cover startup and both typing schedules on slower runners --- Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index bf0b952..a93d8b8 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -546,7 +546,8 @@ private func customScrollingViewportTests() { var scrolled = false var cleared = false var next = false - let deadline = Date(timeIntervalSinceNow: 6) + // Allow shared startup and both command schedules, as in the sequence test below. + let deadline = Date(timeIntervalSinceNow: 20) while !next && Date() < deadline { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) renderer.shiftTimeline(by: -0.1) From 643d02b93a112c15aefb3f60b7feae59ef22190a Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 14:07:57 +0100 Subject: [PATCH 15/17] test: avoid rounding boundary in frame-rate comparison - sample both renderers past the final simulation tick --- Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index a93d8b8..77d3f18 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -352,6 +352,9 @@ private func traceFrameRateTests() { renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - anchor - target) renderer.advance() } + // Sample past the tick boundary so floating-point rounding cannot leave one renderer a frame behind. + renderer.shiftTimeline(by: -0.001) + renderer.advance() frames.append((0..<12).flatMap { row in (0..<40).map { renderer[row, $0] } }) } ok(frames[0].contains(where: \.hasForeground), "Trace scrolling: comparison includes visible digits") From db2090259a7d70936189d77aa0077fa759e2084c Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 14:18:24 +0100 Subject: [PATCH 16/17] test: report wallpaper state on startup failure - distinguish pending launch work from system suspension in CI --- Tests/MatrixWallpaperTests/WallpaperAppTests.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 8b5303a..19ab3d8 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -35,7 +35,13 @@ func wallpaperAppTests() { ScreenSyncCoordinator.shared.registeredCount == 0 && desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg", "launch returns before creating views, registering playback, or acquiring desktops") - check(eventually { desktopWindowCount() == NSScreen.screens.count }, "queued launch starts playback on the main run loop") + let started = eventually { desktopWindowCount() == NSScreen.screens.count } + if !started { + let pending = stored("pendingLaunch", in: host, as: DispatchWorkItem?.self).flatMap { $0 } != nil + let playback = stored("playback", in: host, as: WallpaperPlaybackState.self) + print("# launch pending=\(pending), playback=\(String(describing: playback)), visible=\(desktopWindowCount()), screens=\(NSScreen.screens.count)") + } + check(started, "queued launch starts playback on the main run loop") check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "host launch applies black desktop images") check(desktopWindowCount() == NSScreen.screens.count, "app creates one desktop window per display") check(ScreenSyncCoordinator.shared.registeredCount == NSScreen.screens.count, "app starts every display as one coordinated session") From 83f5f0a742fd697d7b69f49a17b430c5ac1f4e55 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Sat, 31 Oct 2026 14:27:58 +0100 Subject: [PATCH 17/17] test: wait for queued wallpaper startup on slower runners - match the recovery fixture startup allowance - stop dependent checks when startup fails --- Tests/MatrixWallpaperTests/WallpaperAppTests.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 19ab3d8..dd1fcd0 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -35,13 +35,10 @@ func wallpaperAppTests() { ScreenSyncCoordinator.shared.registeredCount == 0 && desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg", "launch returns before creating views, registering playback, or acquiring desktops") - let started = eventually { desktopWindowCount() == NSScreen.screens.count } - if !started { - let pending = stored("pendingLaunch", in: host, as: DispatchWorkItem?.self).flatMap { $0 } != nil - let playback = stored("playback", in: host, as: WallpaperPlaybackState.self) - print("# launch pending=\(pending), playback=\(String(describing: playback)), visible=\(desktopWindowCount()), screens=\(NSScreen.screens.count)") - } + // AppKit can spend over two seconds drawing the previous fixture before servicing queued startup on CI. + let started = eventually(10) { desktopWindowCount() == NSScreen.screens.count } check(started, "queued launch starts playback on the main run loop") + guard started else { return } check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "host launch applies black desktop images") check(desktopWindowCount() == NSScreen.screens.count, "app creates one desktop window per display") check(ScreenSyncCoordinator.shared.registeredCount == NSScreen.screens.count, "app starts every display as one coordinated session")