diff --git a/AGENTS.md b/AGENTS.md index 9677236..c41935c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,7 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature | `MatrixScreenSaverOptions.swift` | Persisted options model and native **Options…** configure sheet | | `MatrixRendererLimits.swift` | Shared numeric clamps/limits (Foundation-only, no AppKit/ScreenSaver dependency) | | `NeoMessageScene.swift` | "Neo" intro message state machine | +| `CmdCaptureScene.swift` | Runs a command in the background, captures its stdout, and reveals it before the trace program | | `Xorshift64.swift` | Deterministic 64-bit RNG | | `TerminalSupport.swift` | Shared terminal-like types: `TerminalSize`, `TerminalColor` | @@ -47,6 +48,7 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature |---|---| | `main.swift` | TAP harness entry point — `ok()` helper, orchestrates test files, prints plan | | `Xorshift64Tests.swift` | Tests for `Xorshift64` RNG determinism | +| `CmdCaptureSceneTests.swift` | Tests for `CmdCaptureScene` phases and `NeoMessageScene` name templating | | `NativeMatrixRendererTests.swift` | Tests for renderer `seedOffset` default and per-display divergence | ### Scripts @@ -189,4 +191,3 @@ feat: add character size options - 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. - diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5488..d907cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 + +## [0.4.2] - 2026-10-30 + +### Features + +- add command capture scene and customizable Neo intro name (#106) +- sign and notarize GitHub releases (#111) + +### Bug Fixes + +- select the highest release version tag (#113) + ## [0.4.1] - 2026-10-27 ### Features diff --git a/README.md b/README.md index bca6ea9..f8fb2d6 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,21 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | 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)* | | 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 | +| 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")* | +| Neo message lines | Custom lines typed during the Neo message intro, up to 10 lines / 256 characters each. Leave empty to use the Name-based default message instead. | *(empty)* | +| Show custom scene | Animate each command using trace's initial cursor blink, natural typing rhythm and final pause, then clear it and execute it. Uses the general typing speed. Only output is shown afterward, appearing immediately as it arrives from the top row. Full screens scroll upward; wide rows are clipped without wrapping. Each command clears the screen first. Commands use a size-configured terminal with combined stdout/stderr and receive `COLUMNS`, `LINES`, and `stty size`. No keyboard input is provided. Commands run until exit or cancellation, not a fixed 12-second deadline; use `ping -c 100 google.com` for 100 replies. A command that never exits keeps its scene active until stopped. Displayed output is limited to the first 64 KiB; excess output is drained. Configure commands with **Edit…** in **Intro scenes**. Skipped if disabled or empty; the enabled trace program follows. | Off | +| Output hold (seconds) | Keep each command's output visible after it finishes, before the next command or scene. Non-negative seconds; 0 advances immediately. | 10 | +| command playback | Radio buttons select all commands in sequence, one random command, or all commands in random order on each scene entry. Random order runs every command once and reshuffles each cycle; all displays agree on the pick or order. Add, edit, remove, or drag command rows to change their execution order in **Edit…**. Both whole-list modes finish each command and its message playback before running the next. | One random command | | 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) | +Custom shell commands execute through `/bin/sh` with the host's permissions. +Only enter commands you trust, and avoid sensitive command text or output: both are visible on the screen saver, +including while the screen is locked. Command text and output are not logged. Live commands run +until exit and retain at most 64 KiB of combined output; stopping or restarting playback +cancels its command. The sandboxed screen saver may not have the same access as the wallpaper app. + ### Wallpaper menubar menu ![menubar.jpg](docs/wallpaper-menubar.jpg) @@ -57,13 +67,14 @@ 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, Neo message scene/speed/lines, custom shell commands/playback, and Scan lines - Preview app for fast iteration without reinstalling ### Repository layout - `Sources/MatrixScreenSaver/MatrixScreenSaverView.swift` - screen saver view, layout, drawing, and option wiring - `Sources/MatrixScreenSaver/NativeMatrixRenderer.swift` - native Matrix scene renderer +- `Sources/MatrixScreenSaver/CmdCaptureScene.swift` - streams command output into scrolling terminal rows before the full trace program - `Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift` - options model and native configure sheet - `Sources/MatrixScreenSaver/TerminalSupport.swift` - small shared terminal types used by the active renderer - `Scripts/install-saver.sh` - shared ditto-based installer used by local installs diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift new file mode 100644 index 0000000..c77136b --- /dev/null +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -0,0 +1,340 @@ +import Foundation +import Darwin + +/// The drawing state snapshot the view uses to render the current cmd capture frame. +struct CmdCaptureRenderState { + /// Visible command and output rows, clipped to the scrolling viewport. + let lines: [String] + /// Uses the same blink cadence as Neo messages, including while waiting for output. + let cursorVisible: Bool +} + +/// Types a command with trace timing, then presents live output without a typing delay. +struct CmdCaptureScene { + /// Stages of capture presentation. + enum Phase: Equatable { + /// Blinking, typing the command, and pausing before execution. + case typingCommand + /// Ready to execute, or waiting for the running command's first output. + case launching + /// Appending rows and holding the completed output. + case presenting + /// Finished or skipped because the command was empty. + case done + } + + /// Default deadline for bounded standalone captures; live scene commands run until exit or cancellation. + static let captureTimeout: TimeInterval = 12.0 + /// Current presentation stage. + private(set) var phase: Phase = .done + /// All captured rows, clipped horizontally but retained for scrolling. + private(set) var lines: [String] = [] + /// Original shell text; display clipping never changes what executes. + private(set) var command = "" + + private var startTime: TimeInterval = 0 + private var lastAdvanceTime: TimeInterval = 0 + private var holdStart: TimeInterval? + private var viewportRows = 1 + private var viewportColumns = 1 + private var charTimings: [TimeInterval] = [] + private var typingEnd: TimeInterval = 0 + private var launchTime: TimeInterval = 0 + private var holdDuration: TimeInterval = 10 + private var visibleCommandCount = 0 + + private var displayCommand: String { + command.components(separatedBy: .newlines).joined(separator: " ") + } + + /// Projected active duration, available only after the command finishes. + private(set) var scheduledDuration: TimeInterval? + + /// Resets presentation without launching a command. + /// Poll the background task's latest snapshot on active animation frames. + /// + /// - Parameters: + /// - startTime: Reference-relative time the scene begins. + /// - command: Command to capture. Empty skips straight to `.done`. + /// - speedFactor: General intro typing multiplier, using trace's natural rhythm. + /// - seed: Shared content seed for identical command timing across displays. + /// - holdDuration: Seconds to retain output after exit; zero advances immediately. + /// - size: Available character viewport, excluding renderer margins. + mutating func reset(startTime: TimeInterval, command: String, speedFactor: Double = 1, + seed: UInt64 = 1, holdDuration: TimeInterval = 10, + size: TerminalSize = TerminalSize(columns: 80, rows: 24)) { + self.startTime = startTime + self.command = command + self.holdDuration = holdDuration.isFinite && holdDuration >= 0 ? holdDuration : 10 + viewportRows = max(size.rows, 1) + viewportColumns = max(size.columns, 1) + var rng = Xorshift64(seed: seed) + let speed = speedFactor.isFinite && speedFactor > 0 ? speedFactor : 1 + charTimings = NativeMatrixRenderer.naturalTypingTimings(for: displayCommand, + baseInterval: 1.0 / 40 / speed, rng: &rng) + typingEnd = 2 + (charTimings.last ?? 0) + launchTime = typingEnd + Double.random(in: 1...3, using: &rng) + visibleCommandCount = 0 + lastAdvanceTime = startTime + holdStart = nil + lines = [] + scheduledDuration = nil + phase = command.isEmpty ? .done : .typingCommand + } + + /// Publishes output immediately in the viewport cleared after command typing. + /// + /// - Parameters: + /// - output: Captured command output. + /// - maxLines: Visible viewport height, clamped to at least one. + /// - maxColumns: Maximum characters per row, clamped to at least one. + /// - now: Same reference-relative clock used by ``advance(now:)``. + /// - isComplete: Whether the shell has exited; only then can the final hold begin. + mutating func receiveCapture(_ output: String, maxLines: Int, maxColumns: Int, now: TimeInterval, + isComplete: Bool = true) { + guard phase == .launching || phase == .presenting, holdStart == nil else { return } + guard !output.isEmpty || isComplete else { return } + viewportRows = max(maxLines, 1) + viewportColumns = max(maxColumns, 1) + var rows = output.replacingOccurrences(of: "\r\n", with: "\n") + .components(separatedBy: "\n") + if rows.last == "" { rows.removeLast() } + lines = (rows.isEmpty ? ["(no output)"] : rows).map { + String(String.UnicodeScalarView($0.unicodeScalars.prefix(max(maxColumns, 1)))) + } + lastAdvanceTime = now + if isComplete { + scheduledDuration = max(now - startTime, 0) + holdDuration + holdStart = now + } + phase = .presenting + } + + /// Excludes a suspended interval without rebuilding the message schedule. + /// + /// - Parameter duration: Seconds during which animation was paused. + mutating func shiftTimeline(by duration: TimeInterval) { + startTime += duration + lastAdvanceTime += duration + if let holdStart { self.holdStart = holdStart + duration } + } + + /// Advances command typing or the post-exit hold using active elapsed time. + /// + /// - Parameter now: Current reference-relative time in seconds. + mutating func advance(now: TimeInterval) { + guard phase != .done else { return } + if phase == .typingCommand { + let elapsed = now - startTime + visibleCommandCount = charTimings.prefix(while: { $0 <= elapsed - 2 }).count + if elapsed >= launchTime { phase = .launching } + } + if let holdStart, now >= holdStart + holdDuration { phase = .done } + lastAdvanceTime = now + } + + /// Snapshot for the native renderer at the current presentation stage. + var renderState: CmdCaptureRenderState { + let elapsed = max(0, lastAdvanceTime - startTime) + let cursorElapsed = phase == .typingCommand + ? (elapsed < 2 ? elapsed : elapsed < typingEnd ? elapsed - 2 : elapsed - typingEnd) + : elapsed + let cursorVisible = Int(cursorElapsed / NeoMessageScene.cursorBlinkInterval) % 2 == 0 + let commandLine = String(String.UnicodeScalarView( + displayCommand.prefix(visibleCommandCount).unicodeScalars.prefix(viewportColumns))) + switch phase { + case .done: + return CmdCaptureRenderState(lines: [], cursorVisible: false) + case .typingCommand: + return CmdCaptureRenderState(lines: [commandLine], cursorVisible: cursorVisible) + case .launching: + return CmdCaptureRenderState(lines: [""], cursorVisible: cursorVisible) + case .presenting: + return CmdCaptureRenderState(lines: Array(lines.suffix(viewportRows)), cursorVisible: cursorVisible) + } + } +} + +/// Single-use synchronous command capture with thread-safe cancellation. +/// Owns a process group so timeout/cancellation also stops ordinary shell pipelines. +/// Commands and captured payloads are never logged; host HOME and sandbox restrictions remain intact. +/// Descendants that deliberately create a new session/process group are outside this group's control. +final class CmdCaptureTask { + private let lock = NSLock() + private var cancelled = false + private var started = false + private var latestOutput: String? + private static let byteLimit = 64 * 1024 + private static let extraPathDirectories = [ + "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", + ] + + /// Prevents launch or asks the running capture to stop; safe from any thread. + /// Running work observes cancellation within its 20 ms poll interval, then cleans up. + func cancel() { + lock.lock() + cancelled = true + lock.unlock() + } + + private var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return cancelled + } + + /// Latest terminal buffer prefix, coalesced under the task lock rather than queued per read. + /// Poll on active frames so a suspended renderer cannot accumulate delivery callbacks. + var outputSnapshot: String? { + lock.lock() + defer { lock.unlock() } + return latestOutput + } + + /// Executes `/bin/sh -c` on the calling background thread, once per task. + /// Stdout and stderr share a terminal so line-buffered programs publish promptly. + /// Retains at most 64 KiB while draining excess bytes; cancellation always kills the process group. + /// Cleanup adds at most 100 ms TERM grace and a one-second exit-event wait; + /// a kernel-delayed exit is reaped by that event without blocking the caller further. + /// + /// - Parameters: + /// - command: Shell command, interpreted with the host environment and extended tool PATH. + /// - timeout: Finite positive deadline, or `nil` to run until exit or cancellation. + /// - size: Character dimensions exposed through COLUMNS, LINES and stdin's terminal size. + /// - Returns: Combined terminal output, including partial output on timeout. Invalid UTF-8 + /// uses replacement characters. Empty on cancellation, launch failure, or repeated use. + func captureOutput(command: String, timeout: TimeInterval? = CmdCaptureScene.captureTimeout, + size: TerminalSize = TerminalSize(columns: 80, rows: 24)) -> String { + guard !command.isEmpty, !command.utf8.contains(0), + timeout.map({ $0.isFinite && $0 > 0 }) ?? true else { return "" } + let deadline = timeout.map { ProcessInfo.processInfo.systemUptime + $0 } ?? .infinity + var master: Int32 = -1 + var slave: Int32 = -1 + var windowSize = winsize(ws_row: UInt16(clamping: max(size.rows, 1)), + ws_col: UInt16(clamping: max(size.columns, 1)), ws_xpixel: 0, ws_ypixel: 0) + guard openpty(&master, &slave, nil, nil, &windowSize) == 0 else { return "" } + defer { close(master); if slave >= 0 { close(slave) } } + // Keep terminal-size ioctls available without waiting for input in an unattended host. + var inputSettings = termios() + guard tcgetattr(slave, &inputSettings) == 0 else { return "" } + inputSettings.c_lflag &= ~tcflag_t(ICANON | ECHO) + inputSettings.c_oflag &= ~tcflag_t(OPOST) + withUnsafeMutableBytes(of: &inputSettings.c_cc) { + $0[Int(VMIN)] = 0 + $0[Int(VTIME)] = 0 + } + guard tcsetattr(slave, TCSANOW, &inputSettings) == 0 else { return "" } + guard fcntl(master, F_SETFD, FD_CLOEXEC) == 0, + fcntl(slave, F_SETFD, FD_CLOEXEC) == 0, + fcntl(master, F_SETFL, O_NONBLOCK) == 0 else { return "" } + + var actions: posix_spawn_file_actions_t? + guard posix_spawn_file_actions_init(&actions) == 0 else { return "" } + defer { posix_spawn_file_actions_destroy(&actions) } + var attributes: posix_spawnattr_t? + guard posix_spawnattr_init(&attributes) == 0 else { return "" } + defer { posix_spawnattr_destroy(&attributes) } + guard posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0, + posix_spawnattr_setpgroup(&attributes, 0) == 0, + posix_spawn_file_actions_adddup2(&actions, slave, STDIN_FILENO) == 0, + posix_spawn_file_actions_adddup2(&actions, slave, STDOUT_FILENO) == 0, + posix_spawn_file_actions_adddup2(&actions, slave, STDERR_FILENO) == 0 else { return "" } + + var environment = ProcessInfo.processInfo.environment + environment["COLUMNS"] = String(windowSize.ws_col) + environment["LINES"] = String(windowSize.ws_row) + let existingPath = environment["PATH"] ?? "" + let existingComponents = Set(existingPath.split(separator: ":").map(String.init)) + environment["PATH"] = (Self.extraPathDirectories.filter { !existingComponents.contains($0) } + [existingPath]) + .filter { !$0.isEmpty }.joined(separator: ":") + let arguments = ["/bin/sh", "-c", command].map { $0.withCString { strdup($0) } } + let variables = environment.map { "\($0.key)=\($0.value)".withCString { strdup($0) } } + defer { (arguments + variables).forEach { free($0) } } + var argv = arguments + [nil] + var envp = variables + [nil] + var pid: pid_t = 0 + + // Serialize the launch boundary with cancel so pre-launch cancellation cannot spawn. + lock.lock() + guard !cancelled, !started, ProcessInfo.processInfo.systemUptime < deadline else { + lock.unlock() + return "" + } + started = true + let result = posix_spawn(&pid, "/bin/sh", &actions, &attributes, &argv, &envp) + lock.unlock() + guard result == 0 else { return "" } + + // Arm before observing exit, but do not reap until the process group has been killed. + let reaped = DispatchSemaphore(value: 0) + let exitSource = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .global()) + exitSource.setEventHandler { + var status: Int32 = 0 + // An exit event can arrive before the child is waitable; reap on this background queue. + while waitpid(pid, &status, 0) == -1 && errno == EINTR {} + exitSource.cancel() + exitSource.setEventHandler {} + reaped.signal() + } + close(slave) + slave = -1 + + var stdout = Data() + var outputOpen = true + var completed = false + while !isCancelled && ProcessInfo.processInfo.systemUptime < deadline { + let previousCount = stdout.count + Self.drain(master, into: &stdout, open: &outputOpen) + if stdout.count != previousCount { + let snapshot = String(decoding: stdout, as: UTF8.self) + lock.lock() + latestOutput = snapshot + lock.unlock() + } + var info = siginfo_t() + // Keep the leader unreaped until group cleanup to prevent PID/group-ID reuse. + let exited = waitid(P_PID, id_t(pid), &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid == pid + if exited && !outputOpen { + completed = true + break + } + var descriptors = [ + pollfd(fd: outputOpen ? master : -1, events: Int16(POLLIN), revents: 0), + ] + _ = poll(&descriptors, nfds_t(descriptors.count), 20) + } + + if !completed { + kill(-pid, SIGTERM) + usleep(100_000) + } + // Kill remaining descendants even when the leader exited and closed both pipes. + kill(-pid, SIGKILL) + exitSource.resume() + // Kernel-delayed exit must not block the caller; the exit event still reaps it later. + _ = reaped.wait(timeout: .now() + 1) + guard !isCancelled else { return "" } + return String(decoding: stdout, as: UTF8.self) + } + + /// Drains a bounded batch so a continuously writing child cannot starve cancellation checks. + /// + /// - Parameters: + /// - fd: Nonblocking read descriptor. + /// - data: Retained stream prefix, capped at ``byteLimit`` bytes. + /// - open: Cleared on EOF or a permanent read error; descriptor ownership stays with the caller. + private static func drain(_ fd: Int32, into data: inout Data, open: inout Bool) { + guard open else { return } + var buffer = [UInt8](repeating: 0, count: 8192) + for _ in 0..<8 { + let count = read(fd, &buffer, buffer.count) + if count > 0 { + data.append(contentsOf: buffer.prefix(min(count, byteLimit - data.count))) + } else { + if count == 0 || (errno != EAGAIN && errno != EINTR) { open = false } + return + } + } + } +} diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index b465a3c..72bbce8 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -5,7 +5,16 @@ struct MatrixScreenSaverOptions: Equatable { private enum Keys { static let neoMessageSceneEnabled = "NeoMessageSceneEnabled" static let neoMessageSpeedFactor = "NeoMessageSpeedFactor" + static let name = "Name" static let neoMessageLines = "NeoMessageLines" + static let cmdSceneEnabled = "CmdSceneEnabled" + /// Separate key so the retired custom speed cannot become an output hold. + static let cmdOutputHoldSeconds = "CmdOutputHoldSeconds" + static let commands = "Commands" + /// Preserves random playback for installations without a stored mode. + static let commandsRunInSequence = "CommandsRunInSequence" + /// Optional shuffle for whole-list playback; absent preserves the saved order. + static let commandsShuffleSequence = "CommandsShuffleSequence" static let numberSceneEnabled = "NumberSceneEnabled" static let twinkleEnabled = "TwinkleEnabled" static let diffuseEnabled = "DiffuseEnabled" @@ -23,7 +32,14 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultNeoMessageSceneEnabled = true static let defaultNeoMessageSpeedFactor = 1.0 - static let defaultNeoMessageLines = NeoMessageScene.defaultLines + static let defaultName = "" + static let defaultNeoMessageLines: [String] = [] + static let defaultCmdSceneEnabled = false + /// Post-exit reading time for each custom command. + static let defaultCmdOutputHoldSeconds = 10.0 + static let defaultCommands: [String] = [] + /// Existing installations continue choosing one random command. + static let defaultCommandsRunInSequence = false static let defaultNumberSceneEnabled = true static let defaultTwinkleEnabled = true static let defaultDiffuseEnabled = true @@ -50,11 +66,20 @@ struct MatrixScreenSaverOptions: Equatable { static let maxNeoMessageLineLength = 256 static let minimumRainDurationSeconds = 1 static let maximumRainDurationSeconds = 9999 + static let maxCommandCount = 20 + static let maxCommandLength = 512 static let neoMessageSceneDescription = "Show the Neo message intro. On by default." - static let neoMessageSpeedFactorDescription = "Speed multiplier for intro scene typing (Neo message and number scene). Default: 1.0." - static let neoMessageLinesDescription = "Lines typed during the Neo message intro (1–10, max 256 chars)." - static let numberSceneDescription = "Show the startup number scene. On by default." + static let neoMessageSpeedFactorDescription = "Typing speed multiplier for Neo, custom commands and trace program. Default: 1.0." + /// Output is immediate; this delay only starts after command completion. + static let cmdOutputHoldSecondsDescription = "Seconds to keep output visible after the command finishes. 0 advances immediately. Default: 10." + static let nameDescription = "Name used in the intro message (\"Wake up, {name}...\"). Empty = Neo." + static let neoMessageLinesDescription = "Lines typed during the Neo message intro (0–10, max 256 chars). Leave empty to use the Name-based default message." + static let cmdSceneDescription = "Execute custom commands and show its output as messages. Off by default." + /// Retained in the editor where users enter commands that execute with host permissions. + static let commandsSecurityWarning = "Commands run with the host's permissions; commands and output are visible even while the screen is locked." + static let commandsDescription = "One shell command per row (up to 20, 512 characters each). Drag rows to change execution order. Overlong commands are ignored. Empty disables the scene." + static let numberSceneDescription = "Show the trace program intro. On by default." static let diffuseDescription = "On by default." static let twinkleDescription = "On by default." static let characterSizeDescription = "Character cell size in pixels. Default: 16×30." @@ -73,7 +98,16 @@ struct MatrixScreenSaverOptions: Equatable { var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled var neoMessageSpeedFactor = defaultNeoMessageSpeedFactor + var name = defaultName var neoMessageLines = defaultNeoMessageLines + var cmdSceneEnabled = defaultCmdSceneEnabled + /// Seconds to keep completed output visible before the next command or scene. + var cmdOutputHoldSeconds = defaultCmdOutputHoldSeconds + var commands = defaultCommands + /// Whether each scene entry runs the whole command list in order. + var commandsRunInSequence = defaultCommandsRunInSequence + /// Shuffles whole-list playback once per scene cycle using the shared display seed. + var commandsShuffleSequence = false var numberSceneEnabled = defaultNumberSceneEnabled var twinkleEnabled = defaultTwinkleEnabled var diffuseEnabled = defaultDiffuseEnabled @@ -96,6 +130,11 @@ struct MatrixScreenSaverOptions: Equatable { characterWidth != previous.characterWidth || characterHeight != previous.characterHeight || neoMessageSceneEnabled != previous.neoMessageSceneEnabled || neoMessageSpeedFactor != previous.neoMessageSpeedFactor || neoMessageLines != previous.neoMessageLines || + (neoMessageLines.isEmpty && name != previous.name) || + cmdSceneEnabled != previous.cmdSceneEnabled || commands != previous.commands || + cmdOutputHoldSeconds != previous.cmdOutputHoldSeconds || + commandsRunInSequence != previous.commandsRunInSequence || + commandsShuffleSequence != previous.commandsShuffleSequence || numberSceneEnabled != previous.numberSceneEnabled || rainRunForever != previous.rainRunForever || rainDurationSeconds != previous.rainDurationSeconds } @@ -105,6 +144,11 @@ struct MatrixScreenSaverOptions: Equatable { NativeMatrixRenderer.Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: neoMessageSpeedFactor, + cmdSceneEnabled: cmdSceneEnabled, + cmdOutputHoldSeconds: cmdOutputHoldSeconds, + commands: commands, + commandsRunInSequence: commandsRunInSequence, + commandsShuffleSequence: commandsShuffleSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -114,6 +158,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview ) @@ -126,13 +171,24 @@ struct MatrixScreenSaverOptions: Equatable { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .map { String($0.prefix(Self.maxNeoMessageLineLength)) } .filter { !$0.isEmpty } - let clamped = Array(result.prefix(Self.maxNeoMessageLineCount)) - return clamped.isEmpty ? Self.defaultNeoMessageLines : clamped + return Array(result.prefix(Self.maxNeoMessageLineCount)) }() + let sanitizedCommands: [String] = commands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + // Clipping shell text can change which files or actions a command targets. + .filter { !$0.isEmpty && $0.count <= Self.maxCommandLength && !$0.utf8.contains(0) } + .prefix(Self.maxCommandCount) + .map { $0 } return MatrixScreenSaverOptions( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, Self.minimumNeoMessageSpeedFactor), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: sanitizedLines, + cmdSceneEnabled: cmdSceneEnabled, + cmdOutputHoldSeconds: cmdOutputHoldSeconds.isFinite && cmdOutputHoldSeconds >= 0 ? cmdOutputHoldSeconds : Self.defaultCmdOutputHoldSeconds, + commands: sanitizedCommands, + commandsRunInSequence: commandsRunInSequence, + commandsShuffleSequence: commandsShuffleSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -167,7 +223,13 @@ struct MatrixScreenSaverOptions: Equatable { defaults.register(defaults: [ Keys.neoMessageSceneEnabled: defaultNeoMessageSceneEnabled, Keys.neoMessageSpeedFactor: defaultNeoMessageSpeedFactor, + Keys.name: defaultName, Keys.neoMessageLines: defaultNeoMessageLines, + Keys.cmdSceneEnabled: defaultCmdSceneEnabled, + Keys.cmdOutputHoldSeconds: defaultCmdOutputHoldSeconds, + Keys.commands: defaultCommands, + Keys.commandsRunInSequence: defaultCommandsRunInSequence, + Keys.commandsShuffleSequence: false, Keys.numberSceneEnabled: defaultNumberSceneEnabled, Keys.twinkleEnabled: defaultTwinkleEnabled, Keys.diffuseEnabled: defaultDiffuseEnabled, @@ -189,7 +251,13 @@ struct MatrixScreenSaverOptions: Equatable { MatrixScreenSaverOptions( neoMessageSceneEnabled: defaults.bool(forKey: Keys.neoMessageSceneEnabled), neoMessageSpeedFactor: defaults.double(forKey: Keys.neoMessageSpeedFactor), + name: defaults.string(forKey: Keys.name) ?? defaultName, neoMessageLines: (defaults.array(forKey: Keys.neoMessageLines) as? [String]) ?? defaultNeoMessageLines, + cmdSceneEnabled: defaults.bool(forKey: Keys.cmdSceneEnabled), + cmdOutputHoldSeconds: defaults.double(forKey: Keys.cmdOutputHoldSeconds), + commands: (defaults.array(forKey: Keys.commands) as? [String]) ?? defaultCommands, + commandsRunInSequence: defaults.bool(forKey: Keys.commandsRunInSequence), + commandsShuffleSequence: defaults.bool(forKey: Keys.commandsShuffleSequence), numberSceneEnabled: defaults.bool(forKey: Keys.numberSceneEnabled), twinkleEnabled: defaults.bool(forKey: Keys.twinkleEnabled), diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), @@ -211,7 +279,13 @@ struct MatrixScreenSaverOptions: Equatable { let options = sanitized() defaults.set(options.neoMessageSceneEnabled, forKey: Keys.neoMessageSceneEnabled) defaults.set(options.neoMessageSpeedFactor, forKey: Keys.neoMessageSpeedFactor) + defaults.set(options.name, forKey: Keys.name) defaults.set(options.neoMessageLines, forKey: Keys.neoMessageLines) + defaults.set(options.cmdSceneEnabled, forKey: Keys.cmdSceneEnabled) + defaults.set(options.cmdOutputHoldSeconds, forKey: Keys.cmdOutputHoldSeconds) + defaults.set(options.commands, forKey: Keys.commands) + defaults.set(options.commandsRunInSequence, forKey: Keys.commandsRunInSequence) + defaults.set(options.commandsShuffleSequence, forKey: Keys.commandsShuffleSequence) defaults.set(options.numberSceneEnabled, forKey: Keys.numberSceneEnabled) defaults.set(options.twinkleEnabled, forKey: Keys.twinkleEnabled) defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) @@ -234,6 +308,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, NSTableViewDataSource, NSTableViewDelegate { private enum ValidationError: LocalizedError { case neoMessageSpeedFactor + case cmdOutputHoldSeconds case rainDensity case frameRate case errorRate @@ -242,7 +317,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, var errorDescription: String? { switch self { case .neoMessageSpeedFactor: - return "Neo message speed must be a positive number." + return "Intro typing speed must be a positive number." + case .cmdOutputHoldSeconds: + return "Output hold must be a finite non-negative number of seconds." case .rainDensity: return "Rain density must be a positive number." case .frameRate: @@ -263,6 +340,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let rootStack = NSStackView() private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Show Neo message scene", target: nil, action: nil) private let neoMessageSpeedFactorField = NSTextField(string: "") + private let cmdOutputHoldSecondsField = NSTextField(string: "") + private let nameField = NSTextField(string: "") private let linesTableView = NSTableView() private let linesScrollView = NSScrollView() private var editorLines: [String] = [] @@ -275,7 +354,33 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private static let removeColumnID = NSUserInterfaceItemIdentifier("remove") private var pendingLines: [String] = MatrixScreenSaverOptions.defaultNeoMessageLines private var linesEditorWindow: NSWindow? - private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show number scene", target: nil, action: nil) + /// The shared list edits only one scene's pending values at a time. + private var editingCommands = false + /// Describes the active list's limits without sharing its persisted values. + private var editorDescriptionLabel: NSTextField? + /// Shell warning shown only while editing executable commands. + private var editorSecurityWarning: NSTextField? + /// Neo-only reset action hidden when editing commands. + private let resetLinesButton = NSButton(title: "Reset lines", target: nil, action: nil) + /// Commands may be empty; Neo retains at least one editable row. + private var minimumEditorRows: Int { editingCommands ? 0 : MatrixScreenSaverOptions.minNeoMessageLineCount } + /// Applies each scene's existing storage limit to the shared list. + private var maximumEditorRows: Int { + editingCommands ? MatrixScreenSaverOptions.maxCommandCount : MatrixScreenSaverOptions.maxNeoMessageLineCount + } + private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Show custom scene", target: nil, action: nil) + /// Scene-owned editor buttons remain available only while their scene is enabled. + private let commandsEditButton = NSButton(title: "Edit…", target: nil, action: nil) + private let linesEditButton = NSButton(title: "Edit…", target: nil, action: nil) + /// Radio controls share an action and parent so AppKit treats them as one group. + private let commandsSequenceRadio = NSButton(radioButtonWithTitle: "In sequence", target: nil, action: nil) + /// Selects a single command on every scene entry. + private let commandsRandomRadio = NSButton(radioButtonWithTitle: "One random command", target: nil, action: nil) + /// Runs the complete list in a newly shuffled order for each cycle. + private let commandsShuffleRadio = NSButton(radioButtonWithTitle: "All commands in random order", target: nil, action: nil) + /// Commands accepted in the editor, not persisted until the main dialog is accepted. + private var pendingCommands: [String] = [] + private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show trace program scene", target: nil, action: nil) private let twinkleCheckbox = NSButton(checkboxWithTitle: "Twinkling effect", target: nil, action: nil) private let diffuseCheckbox = NSButton(checkboxWithTitle: "Glow effect", target: nil, action: nil) private let scanLinesSlider: NSSlider = { @@ -333,7 +438,14 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, func prepare(using options: MatrixScreenSaverOptions) { neoMessageSceneCheckbox.state = options.neoMessageSceneEnabled ? .on : .off neoMessageSpeedFactorField.stringValue = Self.format(options.neoMessageSpeedFactor) + cmdOutputHoldSecondsField.stringValue = Self.format(options.cmdOutputHoldSeconds) + nameField.stringValue = options.name pendingLines = options.neoMessageLines + cmdSceneCheckbox.state = options.cmdSceneEnabled ? .on : .off + pendingCommands = options.commands + commandsSequenceRadio.state = options.commandsRunInSequence && !options.commandsShuffleSequence ? .on : .off + commandsRandomRadio.state = options.commandsRunInSequence ? .off : .on + commandsShuffleRadio.state = options.commandsRunInSequence && options.commandsShuffleSequence ? .on : .off numberSceneCheckbox.state = options.numberSceneEnabled ? .on : .off twinkleCheckbox.state = options.twinkleEnabled ? .on : .off diffuseCheckbox.state = options.diffuseEnabled ? .on : .off @@ -353,6 +465,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters + updateSceneControls() sizeWindowToFitContent() } @@ -399,13 +512,15 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Constructs the native AppKit options sheet content and layout. private func buildWindow() { - window.title = "MatrixScreenSaver Options" + window.title = "Matrix Screen Saver and animated Wallpaper" window.isReleasedWhenClosed = false window.delegate = self characterSizeSlider.target = self characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) neoMessageSpeedFactorField.formatter = Self.numberFormatter + cmdOutputHoldSecondsField.formatter = Self.numberFormatter + cmdOutputHoldSecondsField.delegate = self rainDensityField.formatter = Self.numberFormatter rainDurationField.formatter = Self.integerFormatter frameRateField.formatter = Self.numberFormatter @@ -417,6 +532,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, errorRateField.delegate = self rainRunForeverCheckbox.target = self rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:)) + for checkbox in [neoMessageSceneCheckbox, cmdSceneCheckbox, numberSceneCheckbox] { + checkbox.target = self + checkbox.action = #selector(sceneCheckboxChanged(_:)) + } rootStack.orientation = .vertical rootStack.alignment = .leading @@ -430,7 +549,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, aboutButton.contentTintColor = .linkColor aboutButton.font = .systemFont(ofSize: 13) - let tabs = NSTabView() + // Tab pages lay out during insertion, before Auto Layout sizes the tab view. + let tabs = NSTabView(frame: NSRect(x: 0, y: 0, width: 520, height: 520)) tabs.translatesAutoresizingMaskIntoConstraints = false tabs.addTabViewItem(makeOptionsTab(title: "General", views: [ makeCharacterSizeSection(), @@ -438,9 +558,13 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, makeNumericSection(title: "Frame rate", field: frameRateField, description: MatrixScreenSaverOptions.frameRateDescription), ])) tabs.addTabViewItem(makeOptionsTab(title: "Intro scenes", views: [ - makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription), - makeNeoMessageLinesButtonRow(), - makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription), + makeSceneGroup(views: [ + makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription), + makeTextSection(title: "Name", field: nameField, description: MatrixScreenSaverOptions.nameDescription, fieldWidth: Self.textFieldWidth / 2), + makeNeoMessageLinesButtonRow(), + ]), + makeSceneGroup(views: [makeCustomShellCommandsSection()]), + makeSceneGroup(views: [makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription)]), makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: MatrixScreenSaverOptions.neoMessageSpeedFactorDescription), ])) tabs.addTabViewItem(makeOptionsTab(title: "Rain", views: [ @@ -457,7 +581,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: 520), tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth), ]) @@ -529,6 +653,89 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, sizeWindowToFitContent() } + /// Wraps scene controls in a native, untitled fieldset without changing their actions. + private func makeSceneGroup(views: [NSView]) -> NSBox { + let box = NSBox() + box.titlePosition = .noTitle + box.contentViewMargins = NSSize(width: 8, height: 8) + let stack = NSStackView(views: views) + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + let content = box.contentView! + content.addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), + stack.topAnchor.constraint(equalTo: content.topAnchor), + stack.bottomAnchor.constraint(equalTo: content.bottomAnchor), + ]) + for view in views { + view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true + } + return box + } + + /// Refreshes editable controls without changing the scene's retained values or enable toggle. + private func updateSceneControls() { + cmdOutputHoldSecondsField.isEnabled = cmdSceneCheckbox.state == .on + nameField.isEnabled = neoMessageSceneCheckbox.state == .on + linesEditButton.isEnabled = neoMessageSceneCheckbox.state == .on + for control in [commandsEditButton, commandsSequenceRadio, commandsRandomRadio, commandsShuffleRadio] { + control.isEnabled = cmdSceneCheckbox.state == .on + } + } + + /// Keeps fieldset enablement in sync with the scene toggles. + @objc private func sceneCheckboxChanged(_ sender: NSButton) { + updateSceneControls() + } + + /// Groups shell command controls at their position in the intro sequence. + private func makeCustomShellCommandsSection() -> NSView { + commandsEditButton.target = self + commandsEditButton.action = #selector(editCommands(_:)) + commandsEditButton.setAccessibilityLabel("Edit custom shell commands") + let label = NSTextField(labelWithString: "Commands") + label.font = .systemFont(ofSize: 13, weight: .semibold) + label.widthAnchor.constraint(equalToConstant: 100).isActive = true + let heading = NSStackView(views: [label, commandsEditButton]) + heading.spacing = 12 + for radio in [commandsSequenceRadio, commandsRandomRadio, commandsShuffleRadio] { + radio.target = self + radio.action = #selector(commandModeChanged(_:)) + } + let modes = NSStackView(views: [commandsSequenceRadio, commandsRandomRadio, commandsShuffleRadio]) + modes.orientation = .vertical + modes.alignment = .leading + modes.spacing = 4 + let stack = NSStackView(views: [ + makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), + heading, modes, + makeNumericSection(title: "Output hold", field: cmdOutputHoldSecondsField, + description: MatrixScreenSaverOptions.cmdOutputHoldSecondsDescription), + ]) + cmdOutputHoldSecondsField.setAccessibilityLabel("Command output hold in seconds") + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + return stack + } + + /// Keeps the command playback choices mutually exclusive. + @objc private func commandModeChanged(_ sender: NSButton) { + commandsSequenceRadio.state = sender === commandsSequenceRadio ? .on : .off + commandsRandomRadio.state = sender === commandsRandomRadio ? .on : .off + commandsShuffleRadio.state = sender === commandsShuffleRadio ? .on : .off + } + + /// Opens a separate editor without changing the commands accepted by the main dialog. + @objc private func editCommands(_ sender: Any?) { + editingCommands = true + openListEditor() + } + /// Builds the simple "Neo message lines [Edit…]" row shown in the main sheet. private func makeNeoMessageLinesButtonRow() -> NSView { let stack = NSStackView() @@ -547,18 +754,19 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, label.setContentHuggingPriority(.required, for: .horizontal) label.widthAnchor.constraint(equalToConstant: 100).isActive = true - let editButton = NSButton(title: "Edit…", target: self, action: #selector(editLines(_:))) - editButton.setContentHuggingPriority(.required, for: .horizontal) + linesEditButton.target = self + linesEditButton.action = #selector(editLines(_:)) + linesEditButton.setContentHuggingPriority(.required, for: .horizontal) row.addArrangedSubview(label) - row.addArrangedSubview(editButton) + row.addArrangedSubview(linesEditButton) stack.addArrangedSubview(row) stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.neoMessageLinesDescription)) return stack } - /// Builds the secondary Neo message lines editor window (presented as a sheet). + /// Builds the shared, reorderable scene list editor presented as a sheet. private func buildLinesEditorWindow() -> NSWindow { let editorWindow = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1, height: 1), @@ -614,7 +822,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, linesEditorAddButton = NSButton(title: "+", target: self, action: #selector(addLine(_:))) linesEditorAddButton.bezelStyle = .rounded - let resetLinesButton = NSButton(title: "Reset lines", target: self, action: #selector(resetLines(_:))) + resetLinesButton.target = self + resetLinesButton.action = #selector(resetLines(_:)) let cancelLinesButton = NSButton(title: "Cancel", target: self, action: #selector(cancelLines(_:))) let okLinesButton = NSButton(title: "OK", target: self, action: #selector(applyLines(_:))) okLinesButton.keyEquivalent = "\r" @@ -644,13 +853,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, editorStack.addArrangedSubview(linesScrollView) editorStack.addArrangedSubview(linesEditorAddButton) - editorStack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.neoMessageLinesDescription)) + let description = makeDescriptionLabel("") + let warning = makeDescriptionLabel(MatrixScreenSaverOptions.commandsSecurityWarning) + editorDescriptionLabel = description + editorSecurityWarning = warning + editorStack.addArrangedSubview(description) + editorStack.addArrangedSubview(warning) editorStack.addArrangedSubview(footer) linesScrollView.widthAnchor.constraint(equalTo: editorStack.widthAnchor, constant: -(editorStack.edgeInsets.left + editorStack.edgeInsets.right)).isActive = true footer.widthAnchor.constraint(equalTo: editorStack.widthAnchor, constant: -(editorStack.edgeInsets.left + editorStack.edgeInsets.right)).isActive = true + for label in [description, warning] { + label.widthAnchor.constraint(equalTo: linesScrollView.widthAnchor).isActive = true + } + linesScrollView.widthAnchor.constraint(equalToConstant: 460).isActive = true let contentView = NSView() contentView.translatesAutoresizingMaskIntoConstraints = false @@ -669,9 +887,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Opens the lines editor as a sheet over the main options window. @objc private func editLines(_ sender: Any?) { + editingCommands = false + openListEditor() + } + + /// Loads the selected scene's values without committing edits from the previous sheet. + private func openListEditor() { let editor = linesEditorWindow ?? buildLinesEditorWindow() linesEditorWindow = editor - editorLines = pendingLines + editor.title = editingCommands ? "Custom Shell Commands" : "Neo Message Lines" + editorDescriptionLabel?.stringValue = editingCommands + ? MatrixScreenSaverOptions.commandsDescription : MatrixScreenSaverOptions.neoMessageLinesDescription + editorSecurityWarning?.isHidden = !editingCommands + resetLinesButton.isHidden = editingCommands + linesTableView.setAccessibilityLabel(editingCommands ? "Custom commands" : "Neo message lines") + linesEditorAddButton.setAccessibilityLabel(editingCommands ? "Add command" : "Add message") + editorLines = editingCommands ? pendingCommands : pendingLines linesTableView.reloadData() refreshLinesSection() window.beginSheet(editor) @@ -679,17 +910,23 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Commits the editor lines and closes the editor sheet. @objc private func applyLines(_ sender: Any?) { + linesEditorWindow?.makeFirstResponder(nil) flushEditorLines() - pendingLines = editorLines - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - .map { String($0.prefix(MatrixScreenSaverOptions.maxNeoMessageLineLength)) } - if pendingLines.isEmpty { pendingLines = MatrixScreenSaverOptions.defaultNeoMessageLines } + if editingCommands { + // Never truncate shell syntax into a different executable command; sanitize on main Apply. + pendingCommands = editorLines + } else { + pendingLines = editorLines + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .map { String($0.prefix(MatrixScreenSaverOptions.maxNeoMessageLineLength)) } + } window.endSheet(linesEditorWindow ?? window) } /// Discards editor changes and closes the editor sheet. @objc private func cancelLines(_ sender: Any?) { + linesEditorWindow?.makeFirstResponder(nil) window.endSheet(linesEditorWindow ?? window) } @@ -705,7 +942,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Adds a new empty line to the editor table. @objc private func addLine(_ sender: Any?) { - guard editorLines.count < MatrixScreenSaverOptions.maxNeoMessageLineCount else { return } + guard editorLines.count < maximumEditorRows else { return } flushEditorLines() editorLines.append("") linesTableView.reloadData() @@ -719,7 +956,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Removes the row whose "−" button was tapped. @objc private func removeLine(_ sender: NSButton) { let row = linesTableView.row(for: sender) - guard row >= 0, editorLines.count > MatrixScreenSaverOptions.minNeoMessageLineCount else { return } + guard row >= 0, editorLines.count > minimumEditorRows else { return } flushEditorLines() editorLines.remove(at: row) linesTableView.reloadData() @@ -736,9 +973,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Updates button states and resizes the editor window to fit current row count. private func refreshLinesSection() { let count = editorLines.count - linesEditorAddButton.isEnabled = count < MatrixScreenSaverOptions.maxNeoMessageLineCount - // Resize scroll view height to show all rows without a scrollbar - let tableHeight = CGFloat(count) * (Self.lineRowHeight + linesTableView.intercellSpacing.height) + linesEditorAddButton.isEnabled = count < maximumEditorRows + linesScrollView.hasVerticalScroller = count > 10 + let tableHeight = CGFloat(max(1, min(count, 10))) * (Self.lineRowHeight + linesTableView.intercellSpacing.height) if let constraint = linesScrollHeightConstraint { constraint.constant = tableHeight } else { @@ -774,15 +1011,17 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation { - return dropOperation == .above ? .move : [] + return info.draggingSource as? NSTableView === tableView && dropOperation == .above ? .move : [] } func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool { - guard let srcStr = info.draggingPasteboard.string(forType: Self.dragPboardType), - let srcRow = Int(srcStr) else { return false } + guard info.draggingSource as? NSTableView === tableView, dropOperation == .above, + let srcStr = info.draggingPasteboard.string(forType: Self.dragPboardType), + let srcRow = Int(srcStr), editorLines.indices.contains(srcRow), + (0...editorLines.count).contains(row) else { return false } flushEditorLines() let item = editorLines.remove(at: srcRow) let dest = srcRow < row ? row - 1 : row @@ -824,9 +1063,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, cellView.identifier = identifier let field = NSTextField(string: "") field.isEditable = true + field.delegate = self field.isBordered = false field.drawsBackground = false - field.font = .systemFont(ofSize: 13) field.translatesAutoresizingMaskIntoConstraints = false cellView.addSubview(field) cellView.textField = field @@ -837,13 +1076,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, ]) } cellView.textField?.stringValue = editorLines[row] + cellView.textField?.font = editingCommands ? .monospacedSystemFont(ofSize: 12, weight: .regular) : .systemFont(ofSize: 13) + cellView.textField?.setAccessibilityLabel(editingCommands ? "Command \(row + 1)" : "Message \(row + 1)") return cellView case Self.removeColumnID: let btn = NSButton(title: "−", target: self, action: #selector(removeLine(_:))) btn.bezelStyle = .rounded btn.font = .systemFont(ofSize: 13) - btn.isEnabled = editorLines.count > MatrixScreenSaverOptions.minNeoMessageLineCount + btn.isEnabled = editorLines.count > minimumEditorRows + btn.setAccessibilityLabel(editingCommands ? "Remove command \(row + 1)" : "Remove message \(row + 1)") return btn default: @@ -855,6 +1097,23 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, Self.lineRowHeight } + /// Saves edits before scrolling recycles a row's text field. + func controlTextDidChange(_ notification: Notification) { + guard let field = notification.object as? NSTextField else { return } + let row = linesTableView.row(for: field) + guard editorLines.indices.contains(row) else { return } + editorLines[row] = field.stringValue + } + + /// Prevents AppKit's text substitutions from rewriting executable shell syntax. + func controlTextDidBeginEditing(_ notification: Notification) { + guard editingCommands, let field = notification.object as? NSTextField, + let editor = field.currentEditor() as? NSTextView else { return } + editor.isAutomaticQuoteSubstitutionEnabled = false + editor.isAutomaticDashSubstitutionEnabled = false + editor.isAutomaticSpellingCorrectionEnabled = false + } + /// Builds the scan lines slider row with a live percentage label. private func makeScanLinesSection() -> NSView { let stack = NSStackView() @@ -1105,6 +1364,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let sizeIndex = Int(characterSizeSlider.doubleValue.rounded()) let sizePair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) + let cmdOutputHoldSeconds = try parseDouble(from: cmdOutputHoldSecondsField, error: .cmdOutputHoldSeconds) + guard cmdOutputHoldSeconds.isFinite, cmdOutputHoldSeconds >= 0 else { + throw ValidationError.cmdOutputHoldSeconds + } let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) let rainDurationSeconds = try parseInteger(from: rainDurationField, error: .rainDurationSeconds) let frameRate = try parseDouble(from: frameRateField, error: .frameRate) @@ -1126,12 +1389,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, throw ValidationError.errorRate } - let neoMessageLines = pendingLines.isEmpty ? MatrixScreenSaverOptions.defaultNeoMessageLines : pendingLines - return MatrixScreenSaverOptions( neoMessageSceneEnabled: neoMessageSceneCheckbox.state == .on, neoMessageSpeedFactor: neoMessageSpeedFactor, - neoMessageLines: neoMessageLines, + name: nameField.stringValue, + neoMessageLines: pendingLines, + cmdSceneEnabled: cmdSceneCheckbox.state == .on, + cmdOutputHoldSeconds: cmdOutputHoldSeconds, + commands: pendingCommands, + commandsRunInSequence: commandsSequenceRadio.state == .on || commandsShuffleRadio.state == .on, + commandsShuffleSequence: commandsShuffleRadio.state == .on, numberSceneEnabled: numberSceneCheckbox.state == .on, twinkleEnabled: twinkleCheckbox.state == .on, diffuseEnabled: diffuseCheckbox.state == .on, diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index c6d0b13..e320798 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -95,6 +95,14 @@ final class MatrixScreenSaverView: ScreenSaverView { private var colorCache: [TerminalColor: NSColor] = [:] private var nativeRegularGlyphsByLevel: [[UnicodeScalar: CGImage]] = [] private var nativeBoldGlyphsByLevel: [[UnicodeScalar: CGImage]] = [] + /// Text from commands and names need not belong to the configured rain alphabet. + private struct SupplementalGlyphKey: Hashable { + let scalar: UnicodeScalar + let level: Int + let bold: Bool + } + /// Bounded fallback rasters, invalidated with the font and palette caches. + private var nativeSupplementalGlyphs: [SupplementalGlyphKey: CGImage] = [:] /// Glyph pixels depend on cell-derived font metrics and renderer content, not canvas size. private struct GlyphCacheKey: Equatable { let cellSize: CGSize @@ -340,7 +348,8 @@ final class MatrixScreenSaverView: ScreenSaverView { } else { startTime = ScreenSyncCoordinator.shared.resolvedStartTime() } - guard let time = startTime else { + // Neo waits internally, but custom commands and rain start as soon as the renderer does. + guard let time = startTime, Date.timeIntervalSinceReferenceDate >= time else { sharedFrameBuffer?.refresh() return } @@ -391,6 +400,10 @@ final class MatrixScreenSaverView: ScreenSaverView { ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self)) pendingSceneAdvance = true NSLog("MatrixScreenSaver Neo scene complete — requesting phase advance") + } else if nativeRenderer.consumeCmdSceneDone() { + ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self)) + pendingSceneAdvance = true + NSLog("MatrixScreenSaver cmd capture scene complete — requesting phase advance") } else if nativeRenderer.consumeNumberSceneDone() { ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self)) pendingSceneAdvance = true @@ -638,6 +651,7 @@ final class MatrixScreenSaverView: ScreenSaverView { nativeRegularGlyphsByLevel.removeAll(keepingCapacity: false) nativeGlyphCacheKey = nil nativeBoldGlyphsByLevel.removeAll(keepingCapacity: false) + nativeSupplementalGlyphs.removeAll(keepingCapacity: false) nativeDiffuseColorsByLevel.removeAll(keepingCapacity: false) colorCache.removeAll(keepingCapacity: false) optionsSheetController = nil @@ -934,6 +948,7 @@ final class MatrixScreenSaverView: ScreenSaverView { regularFont: regularFont, boldFont: boldFont, textInsetY: textInsetY, palette: palette, scalars: nativeRenderer.supportedScalars) guard nativeGlyphCacheKey != key else { return } + nativeSupplementalGlyphs.removeAll(keepingCapacity: true) nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont) nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont) nativeGlyphCacheKey = key @@ -1022,7 +1037,18 @@ final class MatrixScreenSaverView: ScreenSaverView { } let level = min(max(cell.foregroundLevel, 0), caches.count - 1) - return caches[level][cell.scalar] + if let image = caches[level][cell.scalar] { return image } + let key = SupplementalGlyphKey(scalar: cell.scalar, level: level, bold: cell.bold) + if let image = nativeSupplementalGlyphs[key] { return image } + let attributes: [NSAttributedString.Key: Any] = [ + .font: cell.bold ? boldFont : regularFont, + .foregroundColor: color(for: nativeRenderer.levelColors[level]), + ] + guard let image = makeNativeGlyphImage(for: cell.scalar, attributes: attributes) else { return nil } + // Bound cached text across command restarts, not just within a single capture. + if nativeSupplementalGlyphs.count >= 256 { nativeSupplementalGlyphs.removeAll(keepingCapacity: true) } + nativeSupplementalGlyphs[key] = image + return image } /// Recreates the persistent frame buffer when the terminal grid size changes. diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 304771f..51ec0e6 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -4,6 +4,14 @@ final class NativeMatrixRenderer { struct Configuration: Equatable { var neoMessageSceneEnabled = true var neoMessageSpeedFactor = 1.0 + var cmdSceneEnabled = false + /// Seconds to retain each command's output after exit. + var cmdOutputHoldSeconds = 10.0 + var commands: [String] = [] + /// Runs every command in list order instead of choosing one per scene entry. + var commandsRunInSequence = false + /// Shuffles the full sequence deterministically for each shared scene seed. + var commandsShuffleSequence = false var numberSceneEnabled = true var twinkleEnabled = true var diffuseEnabled = true @@ -13,7 +21,8 @@ final class NativeMatrixRenderer { var frameRate = 25.0 var errorRate = 1.0 var characters = "" - var neoMessageLines: [String] = NeoMessageScene.defaultLines + var neoMessageName = "" + var neoMessageLines: [String] = [] /// When true the 10-second multi-screen sync delay is skipped so scenes /// start within one second of launch. Set for preview contexts where /// cross-display alignment is unnecessary. @@ -24,6 +33,13 @@ final class NativeMatrixRenderer { Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), + cmdSceneEnabled: cmdSceneEnabled, + cmdOutputHoldSeconds: cmdOutputHoldSeconds.isFinite && cmdOutputHoldSeconds >= 0 ? cmdOutputHoldSeconds : 10, + commands: commands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, + commandsRunInSequence: commandsRunInSequence, + commandsShuffleSequence: commandsShuffleSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -33,10 +49,19 @@ final class NativeMatrixRenderer { frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, - neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, + neoMessageName: neoMessageName.trimmingCharacters(in: .whitespacesAndNewlines), + neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.lines(forName: neoMessageName) : neoMessageLines, skipSyncDelay: skipSyncDelay ) } + + /// Whether the cmd capture scene should actually run this session. Enabling the + /// scene with no commands configured would just flash through an empty capture + /// with nothing to show, so an empty command list falls straight through to the + /// trace program / rain instead. + var shouldRunCmdScene: Bool { + cmdSceneEnabled && !commands.isEmpty + } } struct RenderCell: Equatable { @@ -89,6 +114,7 @@ final class NativeMatrixRenderer { private enum Scene { case neoMessage + case cmdCapture case numberIntro case rainForever } @@ -196,7 +222,6 @@ final class NativeMatrixRenderer { private static let numberIntroCursorBlinkPeriod: TimeInterval = 0.5 // matches NeoMessageScene private static let numberIntroCursorBlinkCount = 2 // 4 half-periods × 0.5 s ≈ 2 s private static let numberIntroTypingInterval: TimeInterval = 1.0 / 40.0 - private static let numberIntroRainFrames = 160 private static let numberIntroBlackoutInterval: TimeInterval = 1.0 private static let numberIntroBlackoutRounds = 10 private static let numberIntroMargin = 2 @@ -256,7 +281,7 @@ final class NativeMatrixRenderer { /// Stable scripted-content date supplied separately when a shared playback anchor was paused. var overrideSceneContentStartTime: TimeInterval? - private var configuration = Configuration() + private var configuration = Configuration().sanitized() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool var supportedScalars: [UnicodeScalar] { @@ -266,6 +291,16 @@ final class NativeMatrixRenderer { } private var neoScene = NeoMessageScene() + private var cmdScene = CmdCaptureScene() + /// Index of the command being revealed during sequential playback. + private var commandIndex = 0 + /// The current command's cancellation handle; stopped renderers never own a capture. + private var cmdCaptureTask: CmdCaptureTask? + /// Results wait for an active frame so suspension cannot consume their display time. + private var pendingCmdCaptureOutput: String? + /// Last applied live snapshot; unchanged buffers do not rebuild the output rows. + private var displayedCmdCaptureOutput: String? + private let cmdCaptureQueue = DispatchQueue(label: "dev.patsch.MatrixScreenSaver.cmdCapture") private var layers = [Layer(), Layer(), Layer()] private var renderCells: [RenderCell] = [] private var stagingRenderCells: [RenderCell] = [] @@ -282,6 +317,7 @@ final class NativeMatrixRenderer { private var rainProducing = true private var restartRequested = false private var neoSceneDoneRequested = false + private var cmdSceneDoneRequested = false private var numberSceneDoneRequested = false private enum NumberIntroPhase { case cursorBlink, typingFirstLine, pauseAfterFirst, secondLine, pauseAfterSecond, rain @@ -301,6 +337,7 @@ final class NativeMatrixRenderer { private var numberIntroPhase: NumberIntroPhase = .cursorBlink private var numberIntroCursorVisible = false + /// Completed trace row shifts at the reference rate, independent of host FPS. private var numberIntroRainFrame = 0 private var sceneStartTime: TimeInterval = 0 // 5-second-quantised anchor — shared across displays /// Immutable session date keeps scripted text identical when displays cross a phase barrier around a pause. @@ -346,13 +383,14 @@ final class NativeMatrixRenderer { dirtyRowCount = 0 } - /// When in the Neo message blank-between-lines phase, returns cursor visibility; - /// otherwise `nil`. The view uses this to draw the cursor directly on-screen - /// (bypassing the frame buffer) so it is guaranteed to appear. - /// Covers all Neo scene phases: startup delay, typing, pause, and blank. + /// Neo cursor drawn directly by both hosts, outside the frame buffer. var neoSceneCursor: (column: Int, visible: Bool)? { - guard activeScene == .neoMessage, neoScene.phase != .done else { return nil } - let state = neoScene.renderState + let state: NeoMessageRenderState + if activeScene == .neoMessage, neoScene.phase != .done { + state = neoScene.renderState + } else { + return nil + } let m = Self.numberIntroMargin let textCount = state.currentLine.map { min(state.visibleCharCount, $0.unicodeScalars.count) } ?? 0 return (column: m + textCount, visible: state.cursorVisible) @@ -377,6 +415,19 @@ final class NativeMatrixRenderer { /// Stops the renderer so no more simulation work is produced. func stop() { running = false + cancelCmdCapture() + } + + deinit { + cmdCaptureTask?.cancel() + } + + /// Invalidates both queued execution and results from the previous scene generation. + private func cancelCmdCapture() { + cmdCaptureTask?.cancel() + cmdCaptureTask = nil + pendingCmdCaptureOutput = nil + displayedCmdCaptureOutput = nil } /// Excludes paused wall-clock time while retaining cells, random state, and scene progress. @@ -389,6 +440,7 @@ final class NativeMatrixRenderer { rainStartTime += duration if let start = overrideSceneStartTime { overrideSceneStartTime = start + duration } neoScene.shiftTimeline(by: duration) + cmdScene.shiftTimeline(by: duration) } /// Returns whether a restart has been requested (by `stepRainFrame()` when @@ -407,6 +459,14 @@ final class NativeMatrixRenderer { return neoSceneDoneRequested } + /// Returns whether the cmd capture scene has completed, then clears the flag. + /// The View uses this to coordinate the scene transition across all displays + /// via the ``ScreenSyncCoordinator``. + func consumeCmdSceneDone() -> Bool { + defer { cmdSceneDoneRequested = false } + return cmdSceneDoneRequested + } + /// Returns whether the Number scene has completed, then clears the flag. /// The View uses this to coordinate the scene transition across all displays /// via the ``ScreenSyncCoordinator``. @@ -421,6 +481,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: transitionFromNeoMessage() + case .cmdCapture: + transitionFromCmdCapture() case .numberIntro: beginRainScene() case .rainForever: @@ -465,6 +527,11 @@ final class NativeMatrixRenderer { columns > 0 && rows > 0 && (previousConfiguration.neoMessageSceneEnabled != sanitizedConfiguration.neoMessageSceneEnabled || + previousConfiguration.cmdSceneEnabled != sanitizedConfiguration.cmdSceneEnabled || + previousConfiguration.cmdOutputHoldSeconds != sanitizedConfiguration.cmdOutputHoldSeconds || + previousConfiguration.commands != sanitizedConfiguration.commands || + previousConfiguration.commandsRunInSequence != sanitizedConfiguration.commandsRunInSequence || + previousConfiguration.commandsShuffleSequence != sanitizedConfiguration.commandsShuffleSequence || previousConfiguration.numberSceneEnabled != sanitizedConfiguration.numberSceneEnabled || previousConfiguration.neoMessageLines != sanitizedConfiguration.neoMessageLines) @@ -509,6 +576,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: stepNeoMessageFrame() + case .cmdCapture: + stepCmdCaptureFrame() case .numberIntro: stepNumberIntroScene() case .rainForever: @@ -574,7 +643,32 @@ final class NativeMatrixRenderer { // MARK: - Number intro scene - private func initNumberIntroScene() { + /// Splits the content columns into ``numberIntroBlackoutRounds`` shuffled groups so the + /// number-rain blackout finishes at the same time regardless of screen width. + private func computeBlackoutRounds(rng: inout Xorshift64) -> [[Int]] { + let margin = Self.numberIntroMargin + let colStart = margin + let colEnd = max(margin + 1, columns - margin) + let contentColumns = colEnd - colStart + let numRounds = Self.numberIntroBlackoutRounds + let columnsPerRound = contentColumns > 0 + ? max(1, Int(ceil(Double(contentColumns) / Double(numRounds)))) + : 1 + var available = Array(colStart.. 0 ? Array(available.shuffled(using: &rng).prefix(count)) : [] + blackoutRounds.append(chosen) + let chosenSet = Set(chosen) + available.removeAll { chosenSet.contains($0) } + } + return blackoutRounds + } + + /// Initializes full trace content; custom playback supplies a fresh clock without changing shared content. + /// - Parameter playbackAnchor: Trace playback start after custom output, or the original Neo-only anchor. + private func initNumberIntroScene(playbackAnchor: TimeInterval? = nil) { numberSceneRNG = Xorshift64(seed: sceneSeed &+ 2) // Anchor: sceneStartTime + however long the Neo scene takes at the configured speed. @@ -582,7 +676,7 @@ final class NativeMatrixRenderer { let neoWallDuration: TimeInterval = configuration.neoMessageSceneEnabled ? neoScene.scheduledDuration : 0 - numberIntroAnchor = sceneStartTime + neoWallDuration + numberIntroAnchor = playbackAnchor ?? (sceneStartTime + neoWallDuration) let cursorBlinkEnd = Double(Self.numberIntroCursorBlinkCount * 2) * Self.numberIntroCursorBlinkPeriod @@ -605,25 +699,7 @@ final class NativeMatrixRenderer { let pause2Duration = Double.random(in: 1...3, using: &numberSceneRNG) let rainStart = pause1End + pause2Duration - let margin = Self.numberIntroMargin - let colStart = margin - let colEnd = max(margin + 1, columns - margin) - let contentColumns = colEnd - colStart - // Divide columns into exactly numberIntroBlackoutRounds groups so all - // screens — regardless of width — finish the blackout in the same time. - let numRounds = Self.numberIntroBlackoutRounds - let columnsPerRound = contentColumns > 0 - ? max(1, Int(ceil(Double(contentColumns) / Double(numRounds)))) - : 1 - var available = Array(colStart.. 0 ? Array(available.shuffled(using: &numberSceneRNG).prefix(count)) : [] - blackoutRounds.append(chosen) - let chosenSet = Set(chosen) - available.removeAll { chosenSet.contains($0) } - } + let blackoutRounds = computeBlackoutRounds(rng: &numberSceneRNG) numberIntroSchedule = NumberIntroSchedule( typingText: typingText, @@ -656,16 +732,15 @@ final class NativeMatrixRenderer { let t = Date.timeIntervalSinceReferenceDate let sceneTime = t - numberIntroAnchor switch numberIntroPhase { - case .cursorBlink: stepCursorBlink(sceneTime: sceneTime) - case .typingFirstLine: stepTypingFirstLine(sceneTime: sceneTime) - case .pauseAfterFirst: stepPauseAfterFirst(sceneTime: sceneTime) - case .secondLine: commitSecondLine() - case .pauseAfterSecond: stepPauseAfterSecond(sceneTime: sceneTime) - case .rain: stepNumberRain(sceneTime: sceneTime) + case .cursorBlink: stepCursorBlink(sceneTime: sceneTime) + case .typingFirstLine: stepTypingFirstLine(sceneTime: sceneTime) + case .pauseAfterFirst: stepPauseAfterFirst(sceneTime: sceneTime) + case .secondLine: commitSecondLine() + case .pauseAfterSecond: stepPauseAfterSecond(sceneTime: sceneTime) + case .rain: stepNumberRain(sceneTime: sceneTime) } if numberIntroPhase == previousPhase { break } } - markAllRowsDirty() } private func stepCursorBlink(sceneTime: TimeInterval) { @@ -738,34 +813,51 @@ final class NativeMatrixRenderer { } } guard activeScene == .numberIntro && !numberSceneDoneRequested else { return } - scrollNumberRain() - self.now += 1 + let expectedFrame = Int(max(0, sceneTime - numberIntroSchedule.rainStart) * Self.referenceFrameRate) + // Higher host FPS must not accelerate whole-row scrolling or repeat unchanged frames. + while numberIntroRainFrame < expectedFrame { + scrollNumberRain() + numberIntroRainFrame += 1 + self.now += 1 + } } /// Writes cursor / typed text into row 0 of the render grid. /// Writes cursor / typed text into the content area top-left (row m, col m). private func setRow0(text: String, typedCount: Int, showCursor: Bool) { let m = Self.numberIntroMargin - guard rows > m, columns > m else { return } + let visibleText = String(String.UnicodeScalarView(text.unicodeScalars.prefix(typedCount))) + setRow(m, text: visibleText, + showCursor: showCursor && visibleText.unicodeScalars.count < columns - m * 2) + } + + /// Writes a visible output row and optional block cursor within the content margins. + private func setRow(_ row: Int, text: String, showCursor: Bool) { + let m = Self.numberIntroMargin + guard row >= 0, row < rows, columns > m * 2 else { return } let baseGreenLevel = max(levelColors.count / 2, 1) let scalars = Array(text.unicodeScalars) - let offset = m * columns + let offset = row * columns let colStart = m let colEnd = columns - m - for col in colStart.. UInt64 { + var z = x &+ 0x9E3779B97F4A7C15 + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + /// Returns cumulative character-appear times for natural-feeling typing. /// Adds random per-character variation, longer pauses at punctuation, and /// rare hesitations. All timings are in seconds. @@ -879,12 +986,14 @@ final class NativeMatrixRenderer { /// Advances the Neo message intro scene by one simulation step. private func stepNeoMessageFrame() { + let previousCursorVisible = neoScene.renderState.cursorVisible neoScene.advance(now: Date.timeIntervalSinceReferenceDate) let state = neoScene.renderState // Never write the cursor to the frame buffer — the view draws it directly // via neoSceneCursor so it is guaranteed to appear in every phase. setRow0(text: state.currentLine ?? "", typedCount: state.visibleCharCount, showCursor: false) - markAllRowsDirty() + // Neo's cursor is composited outside the grid, but still needs a published frame. + if state.cursorVisible != previousCursorVisible { markRowDirty(Self.numberIntroMargin) } if neoScene.phase == .done { if configuration.skipSyncDelay { // Preview context: self-transition immediately @@ -898,7 +1007,9 @@ final class NativeMatrixRenderer { /// Moves from the Neo message scene to the next scene in the sequence. private func transitionFromNeoMessage() { - if configuration.numberSceneEnabled { + if configuration.shouldRunCmdScene { + beginCmdCaptureScene() + } else if configuration.numberSceneEnabled { activeScene = .numberIntro initNumberIntroScene() } else { @@ -907,8 +1018,109 @@ final class NativeMatrixRenderer { } } + /// Starts full trace typing after custom messages clear, excluding capture runtime from its clock. + private func transitionFromCmdCapture() { + if configuration.numberSceneEnabled { + activeScene = .numberIntro + initNumberIntroScene(playbackAnchor: Date.timeIntervalSinceReferenceDate) + } else { + beginRainScene() + stepRainFrame() + } + } + + private func beginCmdCaptureScene() { + activeScene = .cmdCapture + guard running else { return } + cancelCmdCapture() + for i in renderCells.indices { renderCells[i] = Self.blankRenderCell } + for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } + markAllRowsDirty() + + // Recreate the same selection/order on every display and command in this cycle. + // sceneSeed only changes by a few seconds between consecutive launches, and + // Xorshift64's first output is poorly mixed for numerically close seeds, so the + // seed is passed through a splitmix64-style finalizer first to decorrelate it. + var commandPickerRNG = Xorshift64(seed: Self.mixSeed(sceneSeed &+ 5)) + let commands = configuration.commandsShuffleSequence && configuration.commandsRunInSequence + ? configuration.commands.shuffled(using: &commandPickerRNG) : configuration.commands + let command = configuration.commandsRunInSequence + ? commands[commandIndex] + : commands.randomElement(using: &commandPickerRNG) ?? "" + + let playbackAnchor = commandIndex == 0 + ? sceneStartTime + (configuration.neoMessageSceneEnabled ? neoScene.scheduledDuration : 0) + : Date.timeIntervalSinceReferenceDate + cmdScene.reset(startTime: playbackAnchor, command: command, + speedFactor: configuration.neoMessageSpeedFactor, + seed: sceneSeed &+ UInt64(commandIndex) &+ 5, + holdDuration: configuration.cmdOutputHoldSeconds, + size: TerminalSize(columns: columns - Self.numberIntroMargin * 2, + rows: rows - Self.numberIntroMargin * 2)) + } + + private func launchCmdCapture() { + let task = CmdCaptureTask() + cmdCaptureTask = task + let command = cmdScene.command + let size = TerminalSize(columns: max(columns - Self.numberIntroMargin * 2, 1), + rows: max(rows - Self.numberIntroMargin * 2, 1)) + cmdCaptureQueue.async { [weak self] in + let output = task.captureOutput(command: command, timeout: nil, size: size) + DispatchQueue.main.async { + guard let self, self.running, self.cmdCaptureTask === task else { return } + self.pendingCmdCaptureOutput = output + } + } + } + + /// Advances the cmd capture scene by one simulation step. + private func stepCmdCaptureFrame() { + let now = Date.timeIntervalSinceReferenceDate + if let output = pendingCmdCaptureOutput { + pendingCmdCaptureOutput = nil + let m = Self.numberIntroMargin + cmdScene.receiveCapture(output, maxLines: max(rows - m * 2, 1), + maxColumns: max(columns - m * 2, 1), now: now) + } else if let output = cmdCaptureTask?.outputSnapshot, output != displayedCmdCaptureOutput { + displayedCmdCaptureOutput = output + let m = Self.numberIntroMargin + cmdScene.receiveCapture(output, maxLines: max(rows - m * 2, 1), + maxColumns: max(columns - m * 2, 1), now: now, isComplete: false) + } + cmdScene.advance(now: now) + if cmdScene.phase == .launching && cmdCaptureTask == nil { launchCmdCapture() } + let state = cmdScene.renderState + let m = Self.numberIntroMargin + for row in m.. 0, rows > 0 else { if configuration.neoMessageSceneEnabled { activeScene = .neoMessage + } else if configuration.shouldRunCmdScene { + activeScene = .cmdCapture } else if configuration.numberSceneEnabled { activeScene = .numberIntro } else { @@ -946,6 +1160,8 @@ final class NativeMatrixRenderer { for i in renderCells.indices { renderCells[i] = Self.blankRenderCell } for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } markAllRowsDirty() + } else if configuration.shouldRunCmdScene { + beginCmdCaptureScene() } else if configuration.numberSceneEnabled { activeScene = .numberIntro initNumberIntroScene() diff --git a/Sources/MatrixScreenSaver/NeoMessageScene.swift b/Sources/MatrixScreenSaver/NeoMessageScene.swift index 549458d..705e8f0 100644 --- a/Sources/MatrixScreenSaver/NeoMessageScene.swift +++ b/Sources/MatrixScreenSaver/NeoMessageScene.swift @@ -16,12 +16,23 @@ struct NeoMessageScene { case done } - static let defaultLines: [String] = [ - "Wake up, Neo...", - "The Matrix has you.", - "Follow the white rabbit.", - "Knock, knock, Neo.", - ] + /// Name used in the intro message when the user hasn't configured one. + static let fallbackName = "Neo" + + /// Builds the classic 4-line intro message addressed to `name`, falling back to + /// ``fallbackName`` ("Neo") when `name` is empty. + static func lines(forName name: String) -> [String] { + let resolved = name.trimmingCharacters(in: .whitespacesAndNewlines) + let addressee = resolved.isEmpty ? fallbackName : resolved + return [ + "Wake up, \(addressee)...", + "The Matrix has you.", + "Follow the white rabbit.", + "Knock, knock, \(addressee).", + ] + } + + static let defaultLines: [String] = lines(forName: "") static let charsPerSecond: Double = 40.0 static let cursorBlinkInterval: TimeInterval = 0.5 diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index 0e1cfea..a92fab6 100644 --- a/Sources/MatrixWallpaper/WallpaperApp.swift +++ b/Sources/MatrixWallpaper/WallpaperApp.swift @@ -90,6 +90,7 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { /// Installs menu controls and observers, then queues desktop recovery and playback on the main run loop. func applicationDidFinishLaunching(_ notification: Notification) { + installEditingMenu() let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) if let url = Bundle.main.url(forResource: "MenuBarIcon", withExtension: "pdf"), let image = NSImage(contentsOf: url) { @@ -159,6 +160,22 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { DispatchQueue.main.async(execute: startup) } + /// Provides standard text shortcuts through the focused dialog's responder chain. + private func installEditingMenu() { + let mainMenu = NSMenu() + mainMenu.addItem(NSMenuItem()) + mainMenu.items[0].submenu = NSMenu() + let editItem = NSMenuItem(title: "Edit", action: nil, keyEquivalent: "") + let editMenu = NSMenu(title: "Edit") + editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editItem.submenu = editMenu + mainMenu.addItem(editItem) + NSApp.mainMenu = mainMenu + } + /// Refreshes both optional controls when installations or system settings change. func menuWillOpen(_ menu: NSMenu) { loginItems.menuWillOpen(menu) @@ -304,10 +321,12 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate { } catch { let message = error.localizedDescription NSLog("Matrix Wallpaper desktop image: %@", message) + // Black is cosmetic; keep animation running without hiding restore failures. + guard restoring else { return } guard lastWallpaperError != message else { return } lastWallpaperError = message let alert = NSAlert() - alert.messageText = restoring ? "Could not restore the desktop wallpaper" : "Could not set the desktop to black" + alert.messageText = "Could not restore the desktop wallpaper" alert.informativeText = "\(message)\nSaved wallpaper settings are kept for retry on Stop or the next launch." alert.runModal() } diff --git a/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift new file mode 100644 index 0000000..36bce44 --- /dev/null +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -0,0 +1,217 @@ +import Foundation +import Darwin + +/// Runs unit tests for CmdCaptureScene and name-templated NeoMessageScene lines. +func cmdCaptureSceneTests() { + // NeoMessageScene lines(forName:) + let customLines = NeoMessageScene.lines(forName: "Trinity") + ok(customLines.first == "Wake up, Trinity...", "NeoMessageScene: lines(forName:) uses custom name") + ok(customLines.last == "Knock, knock, Trinity.", "NeoMessageScene: lines(forName:) uses custom name in closing line") + + let fallbackLines = NeoMessageScene.lines(forName: "") + ok(fallbackLines.first == "Wake up, Neo...", "NeoMessageScene: empty name falls back to Neo") + + let whitespaceLines = NeoMessageScene.lines(forName: " \t ") + ok(whitespaceLines.first == "Wake up, Neo...", "NeoMessageScene: whitespace name falls back to Neo") + + // Command typing follows trace's initial blink, natural character schedule and final pause. + var scene = CmdCaptureScene() + scene.reset(startTime: 100.0, command: "") + ok(scene.phase == .done, "CmdCaptureScene: empty command resets immediately to done") + + scene.reset(startTime: 100.0, command: "echo hello") + ok(scene.phase == .typingCommand && scene.renderState.lines == [""], + "CmdCaptureScene: starts with a blank command and does not allow execution") + scene.advance(now: 100.55) + ok(!scene.renderState.cursorVisible, "CmdCaptureScene: initial cursor blinks at the trace cadence") + scene.advance(now: 101.99) + ok(scene.renderState.lines == [""] && scene.phase == .typingCommand, + "CmdCaptureScene: waits for trace's two-second initial blink before typing") + var rng = Xorshift64(seed: 1) + let timings = NativeMatrixRenderer.naturalTypingTimings(for: "echo hello", baseInterval: 1.0 / 40, rng: &rng) + let pause = Double.random(in: 1...3, using: &rng) + scene.advance(now: 102 + (timings[0] + timings[1]) / 2) + ok(scene.renderState.lines == ["e"] && scene.phase == .typingCommand, + "CmdCaptureScene: types command characters using trace's natural schedule") + var paused = scene + paused.shiftTimeline(by: 20) + paused.advance(now: 122 + (timings[0] + timings[1]) / 2) + ok(paused.renderState.lines == ["e"] && paused.phase == .typingCommand, + "CmdCaptureScene: suspension preserves command typing without permitting execution") + scene.advance(now: 102 + timings.last! + pause - 0.01) + ok(scene.renderState.lines == ["echo hello"] && scene.phase == .typingCommand, + "CmdCaptureScene: holds the complete command through trace's final pause") + scene.advance(now: 102 + timings.last! + pause + 0.01) + ok(scene.phase == .launching && scene.scheduledDuration == nil, + "CmdCaptureScene: permits execution only after typing and the final pause") + ok(scene.renderState.lines == [""], + "CmdCaptureScene: removes the command after its final pause while waiting for output") + var fast = CmdCaptureScene() + fast.reset(startTime: 100, command: "echo hello", speedFactor: 10) + fast.advance(now: 102 + timings.last! + pause - 0.01) + ok(fast.phase == .launching, "CmdCaptureScene: general typing speed accelerates the command") + + scene.receiveCapture("abcdef\n\nsecond\nthird\n", maxLines: 2, maxColumns: 3, now: 110) + ok(scene.lines == ["abc", "", "sec", "thi"], + "CmdCaptureScene: clips width, preserves blank rows and retains output beyond screen height") + ok(scene.renderState.lines == ["sec", "thi"], "CmdCaptureScene: captured output appears immediately, clipped and scrolled") + scene.advance(now: 119.99) + ok(scene.phase == .presenting, "CmdCaptureScene: output remains visible for ten seconds after exit by default") + scene.shiftTimeline(by: 20) + scene.advance(now: 139.99) + ok(scene.phase == .presenting && scene.renderState.lines == ["sec", "thi"], + "CmdCaptureScene: suspension preserves completed output and its remaining hold") + scene.advance(now: 140) + ok(scene.phase == .done && scene.renderState.lines.isEmpty, "CmdCaptureScene: finishes after final hold") + scene.reset(startTime: 100, command: "next", holdDuration: 0) + ok(scene.renderState.lines == [""] && scene.lines.isEmpty, "CmdCaptureScene: new command clears prior output") + scene.advance(now: 200) + scene.receiveCapture("ready", maxLines: 2, maxColumns: 20, now: 201) + scene.advance(now: 201) + ok(scene.phase == .done, "CmdCaptureScene: zero hold advances immediately after exit") + + var emptyOutputScene = CmdCaptureScene() + emptyOutputScene.reset(startTime: 100.0, command: "true") + emptyOutputScene.advance(now: 200) + emptyOutputScene.receiveCapture("", maxLines: 10, maxColumns: 50, now: 201) + ok(emptyOutputScene.lines == ["(no output)"], "CmdCaptureScene: empty output falls back to placeholder") + + var live = CmdCaptureScene() + live.reset(startTime: 100, command: "stream", holdDuration: 3) + live.advance(now: 200) + live.receiveCapture("one\n", maxLines: 2, maxColumns: 20, now: 201, isComplete: false) + live.advance(now: 220) + ok(live.phase == .presenting && live.renderState.lines == ["one"] && live.scheduledDuration == nil, + "CmdCaptureScene: only output remains visible while an idle command is running") + live.receiveCapture("one\ntwo\n", maxLines: 2, maxColumns: 20, now: 221, isComplete: false) + ok(live.renderState.lines == ["one", "two"], "CmdCaptureScene: later output appends immediately without a command row") + live.receiveCapture("one\ntwo\n", maxLines: 2, maxColumns: 20, now: 230) + live.advance(now: 232.99) + ok(live.phase == .presenting, "CmdCaptureScene: final hold starts at command exit, not first output") + live.advance(now: 233) + ok(live.phase == .done, "CmdCaptureScene: completes only after exit and final hold") + live.reset(startTime: 100, command: "partial stream") + live.advance(now: 200) + live.receiveCapture("a", maxLines: 2, maxColumns: 20, now: 201, isComplete: false) + live.receiveCapture("ab", maxLines: 2, maxColumns: 20, now: 211, isComplete: false) + ok(live.renderState.lines.last == "ab", "CmdCaptureScene: partial-line updates appear immediately without a typing delay") + live.reset(startTime: 100, command: "cursor") + live.advance(now: 200) + live.receiveCapture("a\nb\nc", maxLines: 2, maxColumns: 20, now: 201.1, isComplete: false) + ok(live.renderState.lines == ["b", "c"] && live.renderState.cursorVisible, + "CmdCaptureScene: scrolling output retains its blinking cursor") + live.shiftTimeline(by: 20) + live.advance(now: 221.1) + ok(live.renderState.cursorVisible, "CmdCaptureScene: suspension preserves the cursor blink phase") + live.advance(now: 221.6) + ok(!live.renderState.cursorVisible, "CmdCaptureScene: output cursor continues blinking while the command is idle") + live.receiveCapture("a\nb\nc", maxLines: 2, maxColumns: 20, now: 222) + live.advance(now: 232) + ok(!live.renderState.cursorVisible, "CmdCaptureScene: completion removes the cursor") + + cmdCaptureTaskTests() +} + +/// Exercises only authored commands, with all side effects confined to a fresh temporary directory. +func cmdCaptureTaskTests() { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent("matrix-capture-\(UUID().uuidString)") + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + ok(false, "CmdCaptureTask: creates temporary sandbox") + return + } + defer { try? FileManager.default.removeItem(at: directory) } + let marker = directory.appendingPathComponent("marker").path + let ready = directory.appendingPathComponent("ready").path + let late = directory.appendingPathComponent("late").path + ok(CmdCaptureTask().captureOutput(command: "printf bad > '\(marker)'\0ignored").isEmpty && !FileManager.default.fileExists(atPath: marker), + "CmdCaptureTask: embedded NUL cannot execute a truncated command") + + ok(CmdCaptureTask().captureOutput(command: "printf 'hello\\n'; printf 'diagnostic' >&2") == "hello\ndiagnostic", "CmdCaptureTask: stdout and stderr appear together in terminal order") + ok(CmdCaptureTask().captureOutput(command: "printf ' '; printf 'diagnostic' >&2; exit 1") == " diagnostic", "CmdCaptureTask: diagnostic output survives nonzero exit") + ok(CmdCaptureTask().captureOutput(command: "printf '%s' \"$HOME\"") == (ProcessInfo.processInfo.environment["HOME"] ?? ""), "CmdCaptureTask: inherits host HOME") + let dimensions = CmdCaptureTask().captureOutput(command: "printf '%s %s ' \"$COLUMNS\" \"$LINES\"; stty size", + size: TerminalSize(columns: 37, rows: 9)) + ok(dimensions == "37 9 9 37\n", "CmdCaptureTask: shell environment and native terminal report the viewport dimensions") + ok(CmdCaptureTask().captureOutput(command: "printf before; /bin/cat; printf after", timeout: 1) == "beforeafter", + "CmdCaptureTask: size-aware stdin returns EOF instead of blocking unattended readers") + ok(CmdCaptureTask().captureOutput(command: "test -t 1 && test -t 2 && printf terminal") == "terminal", + "CmdCaptureTask: both output streams are terminals so line-buffered programs flush promptly") + let ping = CmdCaptureTask() + let pingFinished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = ping.captureOutput(command: "/sbin/ping -c 14 127.0.0.1", timeout: nil) + pingFinished.signal() + } + let pingDeadline = ProcessInfo.processInfo.systemUptime + 1.5 + while ping.outputSnapshot?.contains("icmp_seq=0") != true && ProcessInfo.processInfo.systemUptime < pingDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + ok(ping.outputSnapshot?.contains("icmp_seq=0") == true && pingFinished.wait(timeout: .now()) == .timedOut, + "CmdCaptureTask: real ping replies are observable before ping exits") + ok(pingFinished.wait(timeout: .now() + 16) == .success && ping.outputSnapshot?.contains("14 packets transmitted") == true, + "CmdCaptureTask: live ping runs beyond the former twelve-second limit and finishes with statistics") + ok(CmdCaptureTask().captureOutput(command: "printf partial; sleep 2", timeout: 0.1) == "partial", + "CmdCaptureTask: a bounded capture preserves partial output on timeout") + + let reusable = CmdCaptureTask() + _ = reusable.captureOutput(command: "true") + ok(reusable.captureOutput(command: "printf bad > '\(marker)'").isEmpty && !FileManager.default.fileExists(atPath: marker), "CmdCaptureTask: repeated execution cannot spawn") + ok(CmdCaptureTask().captureOutput(command: "printf bad > '\(marker)'", timeout: .nan).isEmpty && !FileManager.default.fileExists(atPath: marker), "CmdCaptureTask: invalid timeout cannot spawn") + + let descriptorsBefore = (0..<256).filter { fcntl(Int32($0), F_GETFD) >= 0 }.count + for _ in 0..<5 { _ = CmdCaptureTask().captureOutput(command: "printf checked") } + let descriptorsAfter = (0..<256).filter { fcntl(Int32($0), F_GETFD) >= 0 }.count + ok(descriptorsBefore == descriptorsAfter, "CmdCaptureTask: repeated captures close pipe descriptors") + + let flood = "(/usr/bin/head -c 262144 /dev/zero | /usr/bin/tr '\\000' x) & /usr/bin/head -c 262144 /dev/zero | /usr/bin/tr '\\000' e >&2; wait" + let large = CmdCaptureTask().captureOutput(command: flood, timeout: 3) + ok(large.utf8.count == 65536 && large.allSatisfy { $0 == "x" || $0 == "e" }, "CmdCaptureTask: drains simultaneous streams with bounded combined output") + let largeError = CmdCaptureTask().captureOutput(command: "/usr/bin/head -c 262144 /dev/zero | /usr/bin/tr '\\000' e >&2", timeout: 3) + ok(largeError.utf8.count == 65536, "CmdCaptureTask: stderr retention is bounded too") + let unicodeBoundary = CmdCaptureTask().captureOutput( + command: "/usr/bin/head -c 65535 /dev/zero | /usr/bin/tr '\\000' x; printf '\\303\\251'", timeout: 3) + ok(unicodeBoundary.hasPrefix(String(repeating: "x", count: 65535)), + "CmdCaptureTask: a UTF-8 sequence crossing the byte cap does not discard the valid prefix") + + let start = ProcessInfo.processInfo.systemUptime + let timedOut = CmdCaptureTask().captureOutput(command: "exec 1>&- 2>&-; /bin/sleep 5", timeout: 0.1) + ok(timedOut.isEmpty && ProcessInfo.processInfo.systemUptime - start < 1.5, "CmdCaptureTask: early EOF does not bypass timeout") + + let cancelled = CmdCaptureTask() + cancelled.cancel() + ok(cancelled.captureOutput(command: "printf bad > '\(marker)'").isEmpty && !FileManager.default.fileExists(atPath: marker), "CmdCaptureTask: cancellation before launch prevents side effects") + + let command = "trap '' TERM; (trap '' TERM; printf '%s' $$ > '\(ready)'; /bin/sleep 0.7; printf escaped > '\(late)') | /bin/cat; wait" + // Repeat because process-exit notification can precede waitpid readiness on macOS. + for _ in 0..<20 { + try? FileManager.default.removeItem(atPath: ready) + let running = CmdCaptureTask() + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + _ = running.captureOutput(command: command, timeout: 3) + finished.signal() + } + let readyDeadline = ProcessInfo.processInfo.systemUptime + 2 + var pid: Int32 = 0 + repeat { + let pidText = (try? String(contentsOfFile: ready, encoding: .utf8)) ?? "" + pid = Int32(pidText) ?? 0 + if pid > 0 { break } + Thread.sleep(forTimeInterval: 0.01) + } while ProcessInfo.processInfo.systemUptime < readyDeadline + ok(pid > 0, "CmdCaptureTask: descendant pipeline starts") + running.cancel() + ok(finished.wait(timeout: .now() + 1.5) == .success, "CmdCaptureTask: cancellation of TERM-refusing pipeline is bounded") + ok(pid > 0 && kill(pid, 0) == -1 && errno == ESRCH, "CmdCaptureTask: cancelled shell is reaped") + } + Thread.sleep(forTimeInterval: 0.8) + ok(!FileManager.default.fileExists(atPath: late), "CmdCaptureTask: cancelled descendants cannot write later") + + let timeoutStart = ProcessInfo.processInfo.systemUptime + _ = CmdCaptureTask().captureOutput(command: command, timeout: 0.1) + ok(ProcessInfo.processInfo.systemUptime - timeoutStart < 1.5, "CmdCaptureTask: TERM-refusing pipeline timeout is bounded") + Thread.sleep(forTimeInterval: 0.8) + ok(!FileManager.default.fileExists(atPath: late), "CmdCaptureTask: timed-out descendants cannot write later") +} diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index 5940e5a..99539b1 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -1,8 +1,19 @@ import Foundation func nativeMatrixRendererTests() { + commandCaptureLifecycleTests() + customTraceHandoffTests() + customShellCommandSequenceTests() + customScrollingViewportTests() + customLiveOutputTests() + introDirtyRowTests() + traceFrameRateTests() ok(NativeMatrixRenderer().seedOffset == 0, "NativeMatrixRenderer: seedOffset defaults to 0") + var named = NativeMatrixRenderer.Configuration() + named.neoMessageName = "Trinity" + ok(named.sanitized().neoMessageLines.first == "Wake up, Trinity...", + "NativeMatrixRenderer: a name customizes default intro lines without explicit lines") let palette = NativeMatrixRenderer().levelColors let hasNoPureWhite = !palette.contains { color in @@ -123,3 +134,358 @@ 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") } + +/// Checks idle intro frames and cursor-only updates through the host's dirty-row contract. +private func introDirtyRowTests() { + for neo in [true, false] { + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = neo + configuration.neoMessageLines = ["X"] + configuration.neoMessageSpeedFactor = 1000 + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 12)) + renderer.overrideSceneStartTime = Date.timeIntervalSinceReferenceDate + renderer.start() + defer { renderer.stop() } + renderer.shiftTimeline(by: -0.1) + renderer.advance() + renderer.clearDirtyRows() + renderer.shiftTimeline(by: -0.1) + ok(!renderer.advance() && !(0..<12).contains(where: renderer.isRowDirty), + "Intro redraw: \(neo ? "Neo" : "trace") skips an unchanged pause frame") + renderer.shiftTimeline(by: -0.4) + ok(renderer.advance() && (0..<12).filter(renderer.isRowDirty) == [NativeMatrixRenderer.contentMargin], + "Intro redraw: \(neo ? "Neo" : "trace") cursor blink invalidates only its text row") + } + + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.numberSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.commands = ["printf X; sleep 2"] + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 12)) + renderer.start() + defer { renderer.stop() } + renderer.shiftTimeline(by: -20) + renderer.advance() + let margin = NativeMatrixRenderer.contentMargin + let deadline = Date(timeIntervalSinceNow: 1) + while renderer[margin, margin].scalar != "X" && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + renderer.advance() + } + ok(renderer[margin, margin].scalar == "X", "Intro redraw: live custom output replaces its command at the top of the grid") + renderer.clearDirtyRows() + ok(!renderer.advance(), "Intro redraw: idle custom output does not request another frame") + renderer.shiftTimeline(by: -0.5) + ok(renderer.advance() && (0..<12).filter(renderer.isRowDirty) == [margin], + "Intro redraw: custom cursor blink invalidates only its output row") +} + +/// Compares trace grid motion over equal elapsed time at different configured frame rates. +private func traceFrameRateTests() { + var frames: [[NativeMatrixRenderer.RenderCell]] = [] + for fps in [25.0, 60.0] { + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.frameRate = fps + configuration.twinkleEnabled = false + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 12)) + renderer.overrideSceneStartTime = Date.timeIntervalSinceReferenceDate + renderer.overrideSceneContentStartTime = 1000 + renderer.start() + defer { renderer.stop() } + let schedule = Mirror(reflecting: renderer).children.first { $0.label == "numberIntroSchedule" }!.value + let rainStart = Mirror(reflecting: schedule).children.first { $0.label == "rainStart" }!.value as! TimeInterval + for frame in 0...Int(fps * 0.4) { + let anchor = Mirror(reflecting: renderer).children.first { $0.label == "numberIntroAnchor" }!.value as! TimeInterval + let target = rainStart + 0.005 + Double(frame) / fps + renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - anchor - target) + 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") + ok(frames[0] == frames[1], "Trace scrolling: equal elapsed time produces the same grid at 25 and 60 FPS") +} + +/// Exercises command side effects through renderer setup, playback, and teardown. +private func commandCaptureLifecycleTests() { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try! FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let output = directory.appendingPathComponent("launches") + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.numberSceneEnabled = false + configuration.commands = ["printf x >> '\(output.path)'"] + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 20)) + renderer.updateConfiguration(configuration) + Thread.sleep(forTimeInterval: 0.2) + ok(!FileManager.default.fileExists(atPath: output.path), + "Command capture: stopped setup and layout never execute commands") + + renderer.start() + Thread.sleep(forTimeInterval: 0.1) + renderer.advance() + ok(!FileManager.default.fileExists(atPath: output.path), + "Command capture: command typing cannot execute shell side effects early") + renderer.shiftTimeline(by: -20) + renderer.advance() + Thread.sleep(forTimeInterval: 0.3) + renderer.stop() + ok((try? String(contentsOf: output, encoding: .utf8)) == "x", + "Command capture: start executes exactly once after stopped setup") + + let delayed = directory.appendingPathComponent("after-stop") + configuration.commands = ["sleep 0.4; printf x > '\(delayed.path)'"] + renderer.updateConfiguration(configuration) + renderer.start() + renderer.shiftTimeline(by: -20) + renderer.advance() + Thread.sleep(forTimeInterval: 0.1) + renderer.stop() + Thread.sleep(forTimeInterval: 0.6) + ok(!FileManager.default.fileExists(atPath: delayed.path), + "Command capture: stop cancels active work and its later side effects") + + configuration.commands = ["printf CAPTURE"] + renderer.updateConfiguration(configuration) + renderer.start() + renderer.shiftTimeline(by: -20) + renderer.advance() + let row = NativeMatrixRenderer.contentMargin + func displaysCapture() -> Bool { + let scalars = (0..<40).map { renderer[row, $0].scalar } + return String(String.UnicodeScalarView(scalars)).contains("CAPTURE") + } + let deadline = Date(timeIntervalSinceNow: 2) + repeat { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.advance() + } while !displaysCapture() && Date() < deadline + ok(displaysCapture(), "Command capture: an active frame reveals the captured output") + let pauseStart = Date.timeIntervalSinceReferenceDate + Thread.sleep(forTimeInterval: 3) + renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - pauseStart) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + renderer.advance() + ok(displaysCapture() && !renderer.consumeCmdSceneDone(), + "Command capture: resume retains output instead of consuming its hold during suspension") + renderer.stop() + + renderer.start() + let capturePauseStart = Date.timeIntervalSinceReferenceDate + RunLoop.current.run(until: Date(timeIntervalSinceNow: 3)) + renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - capturePauseStart) + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) + renderer.advance() + ok(!displaysCapture() && !renderer.consumeCmdSceneDone(), + "Command capture: suspension during command typing does not skip to execution on resume") + renderer.stop() +} + +/// Custom completion must enter the full trace introduction, with its own playback clock. +private func customTraceHandoffTests() { + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.commands = ["printf CUSTOM"] + configuration.cmdOutputHoldSeconds = 0.1 + configuration.numberSceneEnabled = true + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 100, rows: 20)) + renderer.start() + renderer.shiftTimeline(by: -20) + renderer.advance() + defer { renderer.stop() } + var completed = false + let deadline = Date(timeIntervalSinceNow: 10) + while !completed && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.advance() + completed = renderer.consumeCmdSceneDone() + } + ok(completed, "Custom trace: custom messages finish before the display barrier") + renderer.advanceToNextScene() + renderer.advance() + let m = NativeMatrixRenderer.contentMargin + func firstLine() -> String { + String(String.UnicodeScalarView((m..<100).map { renderer[m, $0].scalar })) + } + ok(!firstLine().contains("CUSTOM") && !(m..<100).contains { renderer[m, $0].scalar.properties.numericType != nil }, + "Custom trace: handoff clears custom output and begins blinking, not number rain") + let typingDeadline = Date(timeIntervalSinceNow: 6) + while !firstLine().contains("Call trans opt:") && Date() < typingDeadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.advance() + } + ok(firstLine().contains("Call trans opt:"), "Custom trace: full trace first line types after custom completion") +} + +/// Verifies that the native grid consumes output before shell completion. +private func customLiveOutputTests() { + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.numberSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.neoMessageSpeedFactor = 100 + configuration.commands = ["printf 'FIRST\\n'; sleep 1.5; printf 'SECOND\\n'; sleep 0.2"] + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 12)) + renderer.start() + renderer.shiftTimeline(by: -20) + renderer.advance() + defer { renderer.stop() } + let m = NativeMatrixRenderer.contentMargin + func row(_ index: Int) -> String { + String(String.UnicodeScalarView((m..<(40 - m)).map { renderer[index, $0].scalar })) + } + let deadline = Date(timeIntervalSinceNow: 1) + var first = false + var sawCursor = false + while (!first || !sawCursor) && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + renderer.advance() + first = row(m).hasPrefix("FIRST") + if first { sawCursor = row(m).contains("█") } + } + ok(first && !row(m + 1).contains("SECOND") && !renderer.consumeCmdSceneDone(), + "Custom live output: first row appears while the command is still executing") + ok(sawCursor, "Custom live output: the current output row includes a full-block cursor") + let finalDeadline = Date(timeIntervalSinceNow: 2) + while !row(m + 1).hasPrefix("SECOND") && Date() < finalDeadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + renderer.advance() + } + ok(row(m).hasPrefix("FIRST") && row(m + 1).hasPrefix("SECOND"), + "Custom live output: later rows append without clearing earlier output") + let cursorDeadline = Date(timeIntervalSinceNow: 1) + var movedCursor = false + while !movedCursor && Date() < cursorDeadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + renderer.advance() + movedCursor = !row(m).contains("█") && row(m + 1).contains("█") + } + ok(movedCursor, "Custom live output: cursor follows the bottom output row rather than remaining at the top") +} + +/// Checks clipping, scrolling and per-command clearing through the rendered grid. +private func customScrollingViewportTests() { + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.numberSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.neoMessageSpeedFactor = 100 + configuration.cmdOutputHoldSeconds = 1 + configuration.commandsRunInSequence = true + configuration.commands = ["printf 'first\nsecond\nthird\nlast-wide\n'", "sleep 0.2; printf NEXT"] + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 8, rows: 6)) + renderer.start() + defer { renderer.stop() } + let m = NativeMatrixRenderer.contentMargin + func row(_ index: Int) -> String { + String(String.UnicodeScalarView((m..<(8 - m)).map { renderer[index, $0].scalar })) + } + var scrolled = false + var cleared = false + var next = false + let deadline = Date(timeIntervalSinceNow: 6) + while !next && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) + renderer.shiftTimeline(by: -0.1) + renderer.advance() + if row(m) == "thir" && row(m + 1) == "last" { scrolled = true } + if scrolled && row(m + 1) == " " { cleared = true } + if row(m) == "NEXT" { next = true } + } + ok(scrolled, "Custom viewport: last two rows scroll into the native grid with horizontal clipping") + ok(cleared && next && row(m + 1) == " ", "Custom viewport: next command clears prior rows and shows only new output") +} + +/// Checks command selection through observable shell side effects on each scene entry. +private func customShellCommandSequenceTests() { + let output = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: output) } + let renderer = NativeMatrixRenderer() + var configuration = NativeMatrixRenderer.Configuration() + configuration.neoMessageSceneEnabled = false + configuration.cmdSceneEnabled = true + configuration.numberSceneEnabled = false + configuration.commands = [ + "printf A >> '\(output.path)'; sleep 0.15; printf a >> '\(output.path)'", + "printf b >> '\(output.path)'", + "printf c >> '\(output.path)'", + ] + configuration.commandsRunInSequence = true + configuration.neoMessageSpeedFactor = 100 + configuration.cmdOutputHoldSeconds = 0.1 + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 40, rows: 20)) + for cycle in 1...2 { + renderer.start() + let deadline = Date(timeIntervalSinceNow: 20) + var completed = false + while !completed && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.shiftTimeline(by: -0.1) + renderer.advance() + completed = renderer.consumeCmdSceneDone() + } + renderer.stop() + ok(completed && (try? String(contentsOf: output, encoding: .utf8)) == String(repeating: "Aabc", count: cycle), + "Custom shell commands: every scene entry waits for each command and executes all three in order") + } + try? FileManager.default.removeItem(at: output) + configuration.commandsRunInSequence = false + renderer.updateConfiguration(configuration) + renderer.start() + let deadline = Date(timeIntervalSinceNow: 8) + var completed = false + while !completed && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.shiftTimeline(by: -0.1) + renderer.advance() + completed = renderer.consumeCmdSceneDone() + } + renderer.stop() + let result = try? String(contentsOf: output, encoding: .utf8) + ok(completed && (result == "Aa" || result == "b" || result == "c"), "Custom shell commands: random mode executes only one command") + configuration.commandsRunInSequence = true + configuration.commandsShuffleSequence = true + renderer.updateConfiguration(configuration) + var orders: [String] = [] + for seed in [1000, 1000, 1001, 1002, 1003] { + try? FileManager.default.removeItem(at: output) + renderer.overrideSceneContentStartTime = Double(seed) + renderer.overrideSceneStartTime = Date.timeIntervalSinceReferenceDate + renderer.start() + let deadline = Date(timeIntervalSinceNow: 12) + var completed = false + while !completed && Date() < deadline { + RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.02)) + renderer.shiftTimeline(by: -0.1) + renderer.advance() + completed = renderer.consumeCmdSceneDone() + } + renderer.stop() + let order = (try? String(contentsOf: output, encoding: .utf8)) ?? "" + orders.append(order) + ok(completed && order.sorted() == Array("Aabc") && order.contains("Aa"), + "Shuffled commands: every command runs once and finishes before the next begins") + } + ok(orders[0] == orders[1], "Shuffled commands: matching display seeds produce the same full order") + ok(Set(orders).count > 1, "Shuffled commands: new scene seeds reshuffle the list") +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift index 387a81a..a88752c 100644 --- a/Tests/MatrixScreenSaverTests/main.swift +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -17,6 +17,7 @@ func ok(_ condition: Bool, _ description: String, file: StaticString = #file, li xorshift64Tests() screenSyncCoordinatorTests() +cmdCaptureSceneTests() nativeMatrixRendererTests() wallpaperPlaybackStateTests() sharedWallpaperFrameTests() diff --git a/Tests/MatrixWallpaperTests/ConsumerHost.swift b/Tests/MatrixWallpaperTests/ConsumerHost.swift index 205f3c8..92eb0a7 100644 --- a/Tests/MatrixWallpaperTests/ConsumerHost.swift +++ b/Tests/MatrixWallpaperTests/ConsumerHost.swift @@ -83,6 +83,7 @@ func consumerHostTests() { let configuration: NativeMatrixRenderer.Configuration? = stored("configuration", in: renderer) check(configuration?.skipSyncDelay == true, "live visual options retain independent fallback transitions") options.neoMessageSceneEnabled = true + options.cmdSceneEnabled = false options.numberSceneEnabled = true options.neoMessageLines = ["X"] options.rainRunForever = false diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 46ef4c6..8400464 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() + firstSceneSynchronizationTests() guard let screen = NSScreen.main, let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false), let second = MatrixScreenSaverView(frame: screen.frame, isPreview: false) else { @@ -71,26 +72,85 @@ func sharedSaverTests() { defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() } MatrixScreenSaverOptions.registerDefaults(in: defaults) var options = MatrixScreenSaverOptions.load(from: defaults) + check(!options.commandsShuffleSequence, "existing command playback defaults to unshuffled order") + defaults.set(40, forKey: "CmdTypingSpeedFactor") + check(options.cmdOutputHoldSeconds == 10 && MatrixScreenSaverOptions().cmdOutputHoldSeconds == 10 + && NativeMatrixRenderer.Configuration().cmdOutputHoldSeconds == 10 + && MatrixScreenSaverOptions.load(from: defaults).cmdOutputHoldSeconds == 10, + "output hold defaults to ten without interpreting the retired custom speed as a duration") + options.cmdOutputHoldSeconds = 0 + options.save(to: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).cmdOutputHoldSeconds == 0, + "output hold preserves an explicitly saved zero") options.frameRate = 42 options.neoMessageLines = ["Shared options"] + options.name = "Trinity" + options.neoMessageSceneEnabled = false + options.cmdSceneEnabled = true + options.numberSceneEnabled = false + options.commands = ["printf first", "printf second"] + options.commandsRunInSequence = true + options.commandsShuffleSequence = true + options.neoMessageSpeedFactor = 2 + options.cmdOutputHoldSeconds = 4.5 var applied = false let controller = MatrixScreenSaverOptionsSheetController { edited in edited.save(to: defaults) applied = true } controller.prepare(using: options) + let shuffleRadio: NSButton? = stored("commandsShuffleRadio", in: controller) + check(shuffleRadio?.state == .on, "options restores the shuffled command playback choice") + let randomRadio: NSButton? = stored("commandsRandomRadio", in: controller) + let sequenceRadio: NSButton? = stored("commandsSequenceRadio", in: controller) + randomRadio?.performClick(nil) + check(randomRadio?.state == .on && shuffleRadio?.state == .off && sequenceRadio?.state == .off, + "one random command clears both whole-list playback choices") + shuffleRadio?.performClick(nil) + check(shuffleRadio?.state == .on && randomRadio?.state == .off && sequenceRadio?.state == .off, + "shuffled playback is mutually exclusive with the existing modes") checkOptionsLayout(in: controller.configureSheet()) + checkSceneOptions(controller, options: options) let applyButton = buttons(in: controller.configureSheet().contentView!).first { $0.title == "OK" } check(applyButton != nil, "native options window exposes Apply") applyButton?.performClick(nil) let loaded = MatrixScreenSaverOptions.load(from: defaults) check(applied && loaded.frameRate == 42 && loaded.neoMessageLines == ["Shared options"] && !loaded.numberSceneEnabled, "native Apply validates and delivers options through the reusable callback") + check(loaded.name == "Trinity" && loaded.cmdSceneEnabled && loaded.commands == options.commands + && !loaded.neoMessageSceneEnabled && loaded.commandsRunInSequence && loaded.neoMessageSpeedFactor == 2 + && loaded.cmdOutputHoldSeconds == 4.5 && loaded.rendererConfiguration().cmdOutputHoldSeconds == 4.5, + "tabbed options preserve the Neo name and command settings through Apply and persistence") + check(loaded.commandsShuffleSequence && loaded.rendererConfiguration().commandsShuffleSequence, + "shuffled playback survives Apply, persistence and renderer configuration") + var cancelApplied = false + let cancelController = MatrixScreenSaverOptionsSheetController { _ in cancelApplied = true } + cancelController.prepare(using: options) + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Reset to defaults" }!.performClick(nil) + buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Cancel" }!.performClick(nil) + check(!cancelApplied && MatrixScreenSaverOptions.load(from: defaults).commands == options.commands, + "main Cancel discards edits without invoking persistence") options.frameRate = -10 options.save(to: defaults) check(MatrixScreenSaverOptions.load(from: defaults).frameRate > 0, "shared persistence retains option sanitization") let original = MatrixScreenSaverOptions() + var commandOptions = original + commandOptions.commands = [String(repeating: "x", count: 513), "printf safe", "printf bad\0ignored"] + check(commandOptions.sanitized().commands == ["printf safe"], + "command sanitization rejects invalid text instead of executing a clipped command") + for value in [Double.nan, .infinity, -1] { + commandOptions.cmdOutputHoldSeconds = value + var configuration = NativeMatrixRenderer.Configuration() + configuration.cmdOutputHoldSeconds = value + check(commandOptions.sanitized().cmdOutputHoldSeconds == 10 + && configuration.sanitized().cmdOutputHoldSeconds == 10, + "output hold rejects nonfinite and negative durations in persistence and renderer configuration") + } + commandOptions = original + commandOptions.cmdOutputHoldSeconds = 100 + check(commandOptions.requiresSceneRestart(comparedTo: original), + "output hold changes restart all participating displays") var live = original live.twinkleEnabled.toggle() live.diffuseEnabled.toggle() @@ -107,6 +167,10 @@ func sharedSaverTests() { ("Neo enablement", { $0.neoMessageSceneEnabled.toggle() }), ("intro speed", { $0.neoMessageSpeedFactor = 2 }), ("Neo lines", { $0.neoMessageLines = ["Changed intro"] }), + ("Neo name", { $0.name = "Trinity" }), + ("command enablement", { $0.cmdSceneEnabled.toggle() }), + ("command list", { $0.commands = ["printf hello"] }), + ("command shuffle", { $0.commandsShuffleSequence = true }), ("number intro", { $0.numberSceneEnabled.toggle() }), ("rain cycle mode", { $0.rainRunForever.toggle() }), ("rain cycle duration", { $0.rainDurationSeconds += 1 }), @@ -116,6 +180,53 @@ func sharedSaverTests() { change(&changed) check(changed.requiresSceneRestart(comparedTo: original), "\(name) requests a coordinated scene restart") } + var custom = original + custom.neoMessageLines = ["Custom intro"] + var renamed = custom + renamed.name = "Trinity" + check(!renamed.requiresSceneRestart(comparedTo: custom), + "unused Neo name does not restart custom intro lines") +} + +/// Every first scene must wait for the shared clock, not just Neo's own message timeline. +private func firstSceneSynchronizationTests() { + for scene in ["custom", "trace", "rain"] { + let participant = NSObject() + let coordinator = ScreenSyncCoordinator.shared + coordinator.register(ObjectIdentifier(participant), expectedCount: max(NSScreen.screens.count, 2) + 1, + startupDelay: 0.5) + let views = (0..= $1.frame.maxY }, "\(item.label) sections do not overlap") } - if let number = controls.compactMap({ $0 as? NSButton }).first(where: { $0.title == "Show number scene" }) { - number.performClick(nil) - } if let forever = controls.compactMap({ $0 as? NSButton }).first(where: { $0.title == "Run forever" }), let seconds = controls.compactMap({ $0 as? NSTextField }).first(where: { $0.accessibilityLabel() == "Rain duration in seconds" }) { forever.performClick(nil) @@ -199,6 +334,145 @@ private func checkOptionsLayout(in window: NSWindow) { tabs.selectTabViewItem(at: 0) } +/// Exercises scene ownership and actions without reaching into controller storage. +private func checkSceneOptions(_ controller: MatrixScreenSaverOptionsSheetController, options: MatrixScreenSaverOptions) { + let window = controller.configureSheet() + let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first! + let page = tabs.tabViewItems.first { $0.label == "Intro scenes" }!.view! + tabs.selectTabViewItem(at: 1) + let groups = descendants(of: page).compactMap { $0 as? NSBox } + check(groups.count == 3, "intro scenes have three native fieldsets") + guard groups.count == 3 else { return } + let titles = ["Show Neo message scene", "Show custom scene", "Show trace program scene"] + check(groups.map { buttons(in: $0).first?.title ?? "" } == titles, + "fieldsets follow Neo, custom, trace playback order") + for group in groups { + let controls = descendants(of: group).compactMap { $0 as? NSControl } + check(controls.allSatisfy { group.bounds.contains($0.convert($0.bounds, to: group)) }, + "fieldset keeps its controls inside native bounds") + let stacks = descendants(of: group).compactMap { $0 as? NSStackView }.filter { $0.orientation == .vertical } + check(stacks.allSatisfy { stack in + zip(stack.arrangedSubviews, stack.arrangedSubviews.dropFirst()).allSatisfy { $0.frame.minY >= $1.frame.maxY } + }, "fieldset rows do not overlap") + } + let customCopy = "Execute custom commands and show its output as messages. Off by default." + let labels = descendants(of: groups[1]).compactMap { $0 as? NSTextField } + check(labels.contains { $0.isEditable && $0.accessibilityLabel() == "Command output hold in seconds" && $0.stringValue == "4.5" } + && !labels.contains { $0.stringValue == "Typing speed" || $0.accessibilityLabel() == "Custom typing speed" }, + "custom fieldset replaces independent typing speed with an output hold control") + check(labels.filter { $0.stringValue == customCopy }.count == 1 + && labels.contains { $0.stringValue == "Commands" }, "custom group has exact description and separate Commands row") + check(descendants(of: groups[0]).compactMap { $0 as? NSTextField }.contains { $0.stringValue == "Lines" }, + "Neo fieldset owns its Lines editor") + let speed = descendants(of: page).compactMap { $0 as? NSTextField }.first { $0.isEditable && $0.stringValue == "2" }! + check(!groups.contains { speed.isDescendant(of: $0) }, "shared typing speed is outside the fieldsets") + for (index, group) in groups.enumerated() { + let toggle = buttons(in: group).first { $0.title == titles[index] }! + let editors = descendants(of: group).compactMap { $0 as? NSControl }.filter { + $0 !== toggle && (($0 as? NSTextField)?.isEditable == true || $0 is NSButton) + } + let initiallyOn = toggle.state == .on + let otherEditors = groups.filter { $0 !== group }.flatMap { descendants(of: $0) }.compactMap { $0 as? NSControl } + let otherStates = otherEditors.map(\.isEnabled) + check(toggle.isEnabled && editors.allSatisfy { $0.isEnabled == initiallyOn }, "scene \(index) prepare initializes child enablement") + toggle.performClick(nil) + check(toggle.isEnabled && editors.allSatisfy { $0.isEnabled != initiallyOn } && speed.isEnabled, + "scene \(index) toggle changes only its editable descendants") + check(otherEditors.map(\.isEnabled) == otherStates, "scene \(index) toggle leaves other fieldsets unchanged") + toggle.performClick(nil) + } + let name = descendants(of: groups[0]).compactMap { $0 as? NSTextField }.first { $0.isEditable }! + check(name.stringValue == options.name, "scene toggles retain the Neo name") + let customEdit = buttons(in: groups[1]).first { $0.title == "Edit…" }! + customEdit.performClick(nil) + if let editor = window.attachedSheet { + let table = descendants(of: editor.contentView!).compactMap { $0 as? NSTableView }.first + check(table != nil, "custom commands use the same editable row list as Neo messages") + check(descendants(of: editor.contentView!).compactMap { $0 as? NSTextField }.contains { + $0.stringValue.contains("host's permissions") && $0.stringValue.contains("screen is locked") + }, "command editor retains its security warning") + if let table { + func field(_ row: Int) -> NSTextField { + (table.view(atColumn: 1, row: row, makeIfNecessary: true) as! NSTableCellView).textField! + } + func values() -> [String] { (0.. [String]? { nil } + func resetSpringLoading() {} + func enumerateDraggingItems(options enumOpts: NSDraggingItemEnumerationOptions, for view: NSView?, + classes classArray: [AnyClass], searchOptions: [NSPasteboard.ReadingOptionKey: Any], + using block: (NSDraggingItem, Int, UnsafeMutablePointer) -> Void) {} +} + /// Traverses the native hierarchy, including labels and editable controls. private func descendants(of view: NSView) -> [NSView] { view.subviews.flatMap { [$0] + descendants(of: $0) } diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 827fb47..b42953b 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -5,6 +5,7 @@ func wallpaperAppTests() { wallpaperIconTests() wallpaperSyncMenuTests() wallpaperDeferredLaunchTests() + wallpaperBackgroundFailureTests() let suite = "MatrixWallpaperPlaybackTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! defer { defaults.removePersistentDomain(forName: suite) } @@ -141,7 +142,20 @@ func wallpaperAppTests() { wait(0.1) let optionsWindow = NSApp.windows.first { $0.isVisible && !($0 is DesktopWindow) && $0.canBecomeKey } check(optionsWindow != nil, "Options opens a normal native window while stopped") + let saverOptionsHost = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)! + let saverOptionsWindow = saverOptionsHost.configureSheet + let saverOptionsController: MatrixScreenSaverOptionsSheetController? = stored("optionsSheetController", in: saverOptionsHost) + let wallpaperOptionsController: MatrixScreenSaverOptionsSheetController? = stored("optionsController", in: host) + check(saverOptionsController != nil && wallpaperOptionsController != nil + && saverOptionsWindow === saverOptionsController?.configureSheet() + && optionsWindow === wallpaperOptionsController?.configureSheet(), + "saver and wallpaper both present the shared native options controller") + check(saverOptionsWindow?.contentView?.bounds.size == optionsWindow?.contentView?.bounds.size, + "saver sheet and wallpaper options window use identical dialog dimensions") check(desktopWindowCount() == 0, "opening Options does not start stopped playback") + if let controller = wallpaperOptionsController, let window = optionsWindow { + wallpaperEditingShortcutTests(controller: controller, window: window) + } optionsWindow?.performClose(nil) NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil) @@ -160,6 +174,50 @@ func wallpaperAppTests() { wallpaperTerminationRecoveryTests() } +/// Checks registered shortcuts against the real field editor without requiring foreground activation. +private func wallpaperEditingShortcutTests(controller: MatrixScreenSaverOptionsSheetController, window: NSWindow) { + guard let field: NSTextField = stored("nameField", in: controller) else { + check(false, "options exposes an editable name field") + return + } + let pasteboard = NSPasteboard.general + let savedItems = (pasteboard.pasteboardItems ?? []).map { item in + let saved = NSPasteboardItem() + for type in item.types { + if let data = item.data(forType: type) { saved.setData(data, forType: type) } + } + return saved + } + let original = field.stringValue + let wasEnabled = field.isEnabled + let root: NSStackView? = stored("rootStack", in: controller) + let tabs = root?.arrangedSubviews.compactMap { $0 as? NSTabView }.first + let selectedTab = tabs?.selectedTabViewItem + tabs?.selectTabViewItem(at: 1) + defer { + field.stringValue = original + field.isEnabled = wasEnabled + if let selectedTab { tabs?.selectTabViewItem(selectedTab) } + pasteboard.clearContents() + pasteboard.writeObjects(savedItems) + } + field.isEnabled = true + field.stringValue = "Neo" + window.makeKeyAndOrderFront(nil) + check(window.makeFirstResponder(field) && field.currentEditor() != nil, "options field receives editing focus") + func shortcut(_ key: String) -> Bool { + guard let item = NSApp.mainMenu?.item(withTitle: "Edit")?.submenu?.items.first(where: { + $0.keyEquivalent == key && $0.keyEquivalentModifierMask == .command + }), item.target == nil, let action = item.action, let editor = field.currentEditor() else { return false } + return NSApp.sendAction(action, to: editor, from: item) + } + check(shortcut("a") && (field.currentEditor() as? NSTextView)?.selectedRange() == NSRange(location: 0, length: 3), + "Command-A selects the focused options field") + check(shortcut("c") && pasteboard.string(forType: .string) == "Neo", "Command-C copies selected dialog text") + check(shortcut("x") && field.currentEditor()?.string == "", "Command-X cuts selected dialog text") + check(shortcut("v") && field.currentEditor()?.string == "Neo", "Command-V pastes into the focused dialog field") +} + /// Exercises launch cancellation and notifications before the queued continuation can run. private func wallpaperDeferredLaunchTests() { let suite = "MatrixWallpaperDeferredLaunchTests.\(UUID().uuidString)" @@ -266,6 +324,46 @@ private func wallpaperDeferredLaunchTests() { "resuming deferred launch starts one session using the current displays") } +/// A desktop without readable originals must not block animation or lose its wallpaper. +private func wallpaperBackgroundFailureTests() { + let suite = "MatrixWallpaperBackgroundFailureTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let desktop = DesktopWallpaperFixture() + desktop.unreadable = ["first"] + let controller = desktop.controller() + let host = WallpaperApp(defaults: defaults, desktopWallpaper: controller) + var alerts = 0 + // Dismiss a regression's modal alert so the failing test cannot hang waiting for a click. + let dismissAlert = Timer(timeInterval: 0.02, repeats: true) { _ in + guard let window = NSApp.modalWindow else { return } + alerts += 1 + NSApp.abortModal() + window.orderOut(nil) + } + RunLoop.main.add(dismissAlert, forMode: .default) + RunLoop.main.add(dismissAlert, forMode: .modalPanel) + defer { + dismissAlert.invalidate() + host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) + } + host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification)) + check(eventually(10) { desktopWindowCount() == NSScreen.screens.count }, + "an unreadable original wallpaper does not block animation on any display") + check(alerts == 0 && NSApp.modalWindow == nil, + "failure to set the black background is logged without a modal alert") + check(desktop.images["first"]?.url.path == "/original-a.jpg" + && desktop.images["second"]?.url == controller.blackImageURL, + "a display without a saved original stays untouched while other backgrounds turn black") + NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil) + check(desktop.images["second"]?.url.path == "/original-b.jpg", + "Stop restores saved backgrounds after a partial black-background failure") + desktop.unreadable = [] + NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil) + check(desktop.images.values.allSatisfy { $0.url == controller.blackImageURL }, + "a later Start retries the background once its original is readable") +} + /// Ensures a failed restore cannot block Quit and stale entries cannot replace a newer choice. private func wallpaperTerminationRecoveryTests() { let suite = "MatrixWallpaperRecoveryTests.\(UUID().uuidString)" diff --git a/VERSION b/VERSION index 267577d..2b7c5ae 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.4.2 diff --git a/build-wallpaper.sh b/build-wallpaper.sh index 0215a0b..4603728 100755 --- a/build-wallpaper.sh +++ b/build-wallpaper.sh @@ -19,6 +19,7 @@ swiftc -O -module-name MatrixWallpaper \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ + "$SOURCE_DIR/CmdCaptureScene.swift" \ "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ "$SOURCE_DIR/ScreenSyncCoordinator.swift" \ diff --git a/build.sh b/build.sh index cf7cd37..7ed2ca3 100755 --- a/build.sh +++ b/build.sh @@ -36,6 +36,7 @@ swiftc \ "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ + "$SOURCE_DIR/CmdCaptureScene.swift" \ "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ "$SOURCE_DIR/ScreenSyncCoordinator.swift" \ diff --git a/tests.sh b/tests.sh index a4a6463..196e6a2 100755 --- a/tests.sh +++ b/tests.sh @@ -13,6 +13,7 @@ swiftc \ "$SRC/TerminalSupport.swift" \ "$SRC/Xorshift64.swift" \ "$SRC/NeoMessageScene.swift" \ + "$SRC/CmdCaptureScene.swift" \ "$SRC/MatrixRendererLimits.swift" \ "$SRC/ScreenSyncCoordinator.swift" \ "$SRC/SharedWallpaperFrame.swift" \ @@ -21,6 +22,7 @@ swiftc \ "$ROOT/Sources/MatrixWallpaper/WallpaperPlaybackState.swift" \ "$TESTS/Xorshift64Tests.swift" \ "$TESTS/ScreenSyncCoordinatorTests.swift" \ + "$TESTS/CmdCaptureSceneTests.swift" \ "$TESTS/NativeMatrixRendererTests.swift" \ "$TESTS/WallpaperPlaybackStateTests.swift" \ "$TESTS/SharedWallpaperFrameTests.swift" \ diff --git a/wallpaper-tests.sh b/wallpaper-tests.sh index 5117478..a55328a 100755 --- a/wallpaper-tests.sh +++ b/wallpaper-tests.sh @@ -19,6 +19,7 @@ swiftc "$OPTIMIZATION" -framework Foundation -framework AppKit -framework Screen "$SOURCE_DIR/MatrixScreenSaverOptions.swift" \ "$SOURCE_DIR/Xorshift64.swift" \ "$SOURCE_DIR/NeoMessageScene.swift" \ + "$SOURCE_DIR/CmdCaptureScene.swift" \ "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ "$SOURCE_DIR/ScreenSyncCoordinator.swift" \