From 205f5544af436a72e1042237eb1084b3ce498e3f Mon Sep 17 00:00:00 2001 From: John Doll Date: Fri, 23 Oct 2026 20:32:08 -0500 Subject: [PATCH 01/10] feat: add command capture scene and customizable Neo intro name - Add CmdCaptureScene to execute background shell commands and reveal stdout before the trace program - Add configurable settings for command capture list and Neo intro name in options sheet - Add unit tests for CmdCaptureScene lifecycle and NeoMessageScene name templating - Update build and test scripts for saver and wallpaper targets --- AGENTS.md | 3 + README.md | 8 +- .../MatrixScreenSaver/CmdCaptureScene.swift | 235 ++++++++++++++++ .../MatrixScreenSaverOptions.swift | 99 ++++++- .../MatrixScreenSaverView.swift | 4 + .../NativeMatrixRenderer.swift | 258 ++++++++++++++++-- .../MatrixScreenSaver/NeoMessageScene.swift | 23 +- .../CmdCaptureSceneTests.swift | 40 +++ Tests/MatrixScreenSaverTests/main.swift | 1 + Tests/MatrixWallpaperTests/ConsumerHost.swift | 1 + build-wallpaper.sh | 1 + build.sh | 1 + tests.sh | 2 + wallpaper-tests.sh | 1 + 14 files changed, 633 insertions(+), 44 deletions(-) create mode 100644 Sources/MatrixScreenSaver/CmdCaptureScene.swift create mode 100644 Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift diff --git a/AGENTS.md b/AGENTS.md index 9677236..de6258f 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 @@ -58,6 +60,7 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature | `install.sh` | Builds and installs to `~/Library/Screen Savers/`, kills `ScreenSaverEngine` | | `preview.sh` | Builds and launches the saver in the preview host for fast iteration | | `Scripts/install-saver.sh` | Shared installer (strips quarantine, copies with `ditto`, verifies hash) | +| `Scripts/apply-theme.sh` | Applies the full desktop theme (dark mode/accent, wallpaper, iTerm2 profile, Terminal preset) and installs/selects the saver — see `theme/` | ### CI — `.github/workflows/` diff --git a/README.md b/README.md index 88f33b7..173c2df 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | 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 | +| 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)* | +| cmd capture scene | Run a command and reveal its captured stdout before the trace program (Number scene). Runs after the Neo message and before the Number scene. Skipped entirely if disabled or if no commands are configured. | Off | +| cmd commands | One shell command per line. A random one runs each time the screen saver starts (all displays in a multi-monitor setup agree on the same pick). Empty disables the scene. | *(empty)* | | 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) | @@ -57,13 +60,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, cmd capture scene/commands, 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` - runs a command, captures its output, and reveals it before the 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..f505f78 --- /dev/null +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -0,0 +1,235 @@ +import Foundation +import Darwin + +/// The drawing state snapshot the view uses to render the current cmd capture frame. +struct CmdCaptureRenderState { + let lines: [String] + let visibleLineCount: Int + let statusLine: String? +} + +/// Time-driven state machine that runs an external command in the background, +/// captures its stdout, and reveals the captured lines a row at a time before +/// handing off to the next scene (the trace program / number intro). +struct CmdCaptureScene { + enum Phase: Equatable { + case launching + case revealing + case holding + case done + } + + static let linesPerSecond: Double = 14.0 + static let holdDuration: TimeInterval = 2.5 + static let captureTimeout: TimeInterval = 12.0 + static let statusLineText = "Querying..." + + private(set) var phase: Phase = .done + private(set) var visibleLineCount: Int = 0 + private(set) var lines: [String] = [] + + private var startTime: TimeInterval = 0 + private var captureElapsed: TimeInterval? + private var revealEndElapsed: TimeInterval = 0 + private var holdEndElapsed: TimeInterval = 0 + + /// Wall-clock duration of the scene once capture completes; `nil` while still launching. + var scheduledDuration: TimeInterval? { + captureElapsed == nil ? nil : holdEndElapsed + } + + /// Resets the scene for a new session. Does not launch anything itself — callers run + /// `command` (e.g. via ``captureOutput(command:timeout:)`` on a background queue) and + /// report the result back through ``receiveCapture(_:maxLines:maxColumns:now:)``. + /// + /// - Parameters: + /// - startTime: Reference-relative wall-clock time the scene begins. + /// - command: The command that will be run. Empty skips straight to `.done`. + mutating func reset(startTime: TimeInterval, command: String) { + self.startTime = startTime + lines = [] + visibleLineCount = 0 + captureElapsed = nil + revealEndElapsed = 0 + holdEndElapsed = 0 + phase = command.isEmpty ? .done : .launching + } + + /// Records the captured output once the background command finishes. + /// `now` is the same reference-relative clock used by `advance(now:)`. + mutating func receiveCapture(_ output: String, maxLines: Int, maxColumns: Int, now: TimeInterval) { + guard phase == .launching else { return } + let trimmedLines = output + .split(separator: "\n", omittingEmptySubsequences: false) + .map { String($0.prefix(max(maxColumns, 1))) } + .filter { !$0.isEmpty } + lines = trimmedLines.isEmpty ? ["(no output)"] : Array(trimmedLines.prefix(max(maxLines, 1))) + + let elapsed = max(now - startTime, 0) + captureElapsed = elapsed + let revealDuration = Double(lines.count) / Self.linesPerSecond + revealEndElapsed = elapsed + revealDuration + holdEndElapsed = revealEndElapsed + Self.holdDuration + phase = .revealing + } + + mutating func advance(now: TimeInterval) { + guard phase != .done else { return } + guard let captureElapsed else { return } // still launching; waiting on receiveCapture + let sceneTime = now - startTime + + if sceneTime < captureElapsed { + visibleLineCount = 0 + phase = .revealing + } else if sceneTime < revealEndElapsed { + let revealElapsed = sceneTime - captureElapsed + visibleLineCount = min(lines.count, Int(revealElapsed * Self.linesPerSecond) + 1) + phase = .revealing + } else if sceneTime < holdEndElapsed { + visibleLineCount = lines.count + phase = .holding + } else { + visibleLineCount = lines.count + phase = .done + } + } + + var renderState: CmdCaptureRenderState { + switch phase { + case .done: + return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: nil) + case .launching: + return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: Self.statusLineText) + case .revealing, .holding: + return CmdCaptureRenderState(lines: lines, visibleLineCount: visibleLineCount, statusLine: nil) + } + } + + /// Runs `command` via `/bin/sh -c` on the calling thread (intended to be a background + /// queue) and returns captured stdout, or an empty string if the command fails, is + /// missing, or exceeds `timeout`. Reads both pipes concurrently with process execution + /// to avoid the classic `Process`/`Pipe` deadlock on large output. stderr is always + /// logged via `NSLog` for diagnostics, and is used as a fallback result when stdout is + /// empty so a failing command (e.g. a sandboxed screen saver process unable to reach a + /// tool's usual environment) reads its failure reason on screen instead of just + /// "(no output)". + /// Common Homebrew/system binary directories, prepended to `PATH` so commands resolve + /// the same way they would from an interactive shell. The screen saver host process + /// (`legacyScreenSaver.appex`) is sandboxed and its inherited `PATH` typically omits + /// these, so tools like `kubectl` installed under `/usr/local/bin` or + /// `/opt/homebrew/bin` otherwise fail with "command not found". + private static let extraPathDirectories = [ + "/opt/homebrew/bin", "/opt/homebrew/sbin", + "/usr/local/bin", "/usr/local/sbin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", + ] + + /// The real user home directory, read from the system password database rather than + /// the `HOME` environment variable. App Sandbox redirects `HOME` for the (sandboxed) + /// screen saver host process to a container path, which breaks tools that locate their + /// config relative to home (e.g. `kubectl` looking for `~/.kube/config`). + private static var realHomeDirectory: String? { + guard let pw = getpwuid(getuid()), let dir = pw.pointee.pw_dir else { return nil } + return String(cString: dir) + } + + static func captureOutput(command: String, timeout: TimeInterval = captureTimeout) -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + + var environment = ProcessInfo.processInfo.environment + let existingPath = environment["PATH"] ?? "" + let existingComponents = Set(existingPath.split(separator: ":").map(String.init)) + let mergedPath = (extraPathDirectories.filter { !existingComponents.contains($0) } + [existingPath]) + .filter { !$0.isEmpty } + .joined(separator: ":") + environment["PATH"] = mergedPath + if let realHome = realHomeDirectory { + environment["HOME"] = realHome + } + process.environment = environment + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + NSLog("MatrixScreenSaver cmd capture failed to launch '%@': %@", command, error.localizedDescription) + return "" + } + + var capturedOutput = Data() + var capturedError = Data() + let readQueue = DispatchQueue(label: "dev.patsch.MatrixScreenSaver.cmdCapture.read", attributes: .concurrent) + let outputDone = DispatchSemaphore(value: 0) + let errorDone = DispatchSemaphore(value: 0) + readQueue.async { + capturedOutput = outputPipe.fileHandleForReading.readDataToEndOfFile() + outputDone.signal() + } + readQueue.async { + capturedError = errorPipe.fileHandleForReading.readDataToEndOfFile() + errorDone.signal() + } + + let deadline = DispatchTime.now() + timeout + guard outputDone.wait(timeout: deadline) == .success, + errorDone.wait(timeout: deadline) == .success else { + process.terminate() + NSLog("MatrixScreenSaver cmd capture timed out after %.1fs: '%@'", timeout, command) + return "" + } + process.waitUntilExit() + + let stdout = String(data: capturedOutput, encoding: .utf8) ?? "" + let stderr = String(data: capturedError, encoding: .utf8) ?? "" + logCapture(command: command, exitCode: process.terminationStatus, stdout: stdout, stderr: stderr, environment: environment) + if !stderr.isEmpty { + NSLog("MatrixScreenSaver cmd capture stderr for '%@' (exit %d): %@", command, process.terminationStatus, stderr) + } + if stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !stderr.isEmpty { + return stderr + } + return stdout + } + + /// Appends a full record of a capture attempt (command, exit code, PATH/HOME used, + /// stdout, stderr) to a plain-text log file under the process's Logs directory. + /// Exists purely for troubleshooting sandboxed-environment failures (missing PATH + /// entries, redirected HOME, network restrictions) from outside the sandbox, since the + /// on-screen output is otherwise the only place this ever surfaces. + private static func logCapture(command: String, exitCode: Int32, stdout: String, stderr: String, environment: [String: String]) { + guard let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("Logs", isDirectory: true) else { return } + try? FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + let logFile = logsDir.appendingPathComponent("MatrixScreenSaver-cmd-debug.log") + + let entry = """ + --- + \(Date()) + command: \(command) + exitCode: \(exitCode) + PATH: \(environment["PATH"] ?? "") + HOME: \(environment["HOME"] ?? "") + stdout (\(stdout.utf8.count) bytes): + \(stdout) + stderr (\(stderr.utf8.count) bytes): + \(stderr) + + """ + + if let data = entry.data(using: .utf8) { + if let handle = try? FileHandle(forWritingTo: logFile) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: logFile) + } + } + } +} diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 6849323..443ee5a 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -5,7 +5,10 @@ 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" + static let commands = "Commands" static let numberSceneEnabled = "NumberSceneEnabled" static let twinkleEnabled = "TwinkleEnabled" static let diffuseEnabled = "DiffuseEnabled" @@ -23,7 +26,10 @@ 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 + static let defaultCommands: [String] = [] static let defaultNumberSceneEnabled = true static let defaultTwinkleEnabled = true static let defaultDiffuseEnabled = true @@ -50,10 +56,15 @@ 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 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 = "Run a command and reveal its captured output before the trace program. Off by default — most installs won't have a command configured." + static let commandsDescription = "One shell command per line. A random one runs each time the screen saver starts. Empty disables the scene." static let numberSceneDescription = "Show the startup number scene. On by default." static let diffuseDescription = "On by default." static let twinkleDescription = "On by default." @@ -73,7 +84,10 @@ struct MatrixScreenSaverOptions: Equatable { var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled var neoMessageSpeedFactor = defaultNeoMessageSpeedFactor + var name = defaultName var neoMessageLines = defaultNeoMessageLines + var cmdSceneEnabled = defaultCmdSceneEnabled + var commands = defaultCommands var numberSceneEnabled = defaultNumberSceneEnabled var twinkleEnabled = defaultTwinkleEnabled var diffuseEnabled = defaultDiffuseEnabled @@ -105,6 +119,8 @@ struct MatrixScreenSaverOptions: Equatable { NativeMatrixRenderer.Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: neoMessageSpeedFactor, + cmdSceneEnabled: cmdSceneEnabled, + commands: commands, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -114,6 +130,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview ) @@ -126,13 +143,21 @@ 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) } + .map { String($0.prefix(Self.maxCommandLength)) } + .filter { !$0.isEmpty } + .prefix(Self.maxCommandCount) + .map { $0 } return MatrixScreenSaverOptions( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, Self.minimumNeoMessageSpeedFactor), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: sanitizedLines, + cmdSceneEnabled: cmdSceneEnabled, + commands: sanitizedCommands, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -167,7 +192,10 @@ struct MatrixScreenSaverOptions: Equatable { defaults.register(defaults: [ Keys.neoMessageSceneEnabled: defaultNeoMessageSceneEnabled, Keys.neoMessageSpeedFactor: defaultNeoMessageSpeedFactor, + Keys.name: defaultName, Keys.neoMessageLines: defaultNeoMessageLines, + Keys.cmdSceneEnabled: defaultCmdSceneEnabled, + Keys.commands: defaultCommands, Keys.numberSceneEnabled: defaultNumberSceneEnabled, Keys.twinkleEnabled: defaultTwinkleEnabled, Keys.diffuseEnabled: defaultDiffuseEnabled, @@ -189,7 +217,10 @@ 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), + commands: (defaults.array(forKey: Keys.commands) as? [String]) ?? defaultCommands, numberSceneEnabled: defaults.bool(forKey: Keys.numberSceneEnabled), twinkleEnabled: defaults.bool(forKey: Keys.twinkleEnabled), diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), @@ -211,7 +242,10 @@ 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.commands, forKey: Keys.commands) defaults.set(options.numberSceneEnabled, forKey: Keys.numberSceneEnabled) defaults.set(options.twinkleEnabled, forKey: Keys.twinkleEnabled) defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) @@ -263,6 +297,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private let rootStack = NSStackView() private let neoMessageSceneCheckbox = NSButton(checkboxWithTitle: "Activate", target: nil, action: nil) private let neoMessageSpeedFactorField = NSTextField(string: "") + private let nameField = NSTextField(string: "") private let linesTableView = NSTableView() private let linesScrollView = NSScrollView() private var editorLines: [String] = [] @@ -275,6 +310,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private static let removeColumnID = NSUserInterfaceItemIdentifier("remove") private var pendingLines: [String] = MatrixScreenSaverOptions.defaultNeoMessageLines private var linesEditorWindow: NSWindow? + private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Activate", target: nil, action: nil) + private let commandsTextView = NSTextView() + private let commandsScrollView = NSScrollView() private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Activate", 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) @@ -333,7 +371,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, func prepare(using options: MatrixScreenSaverOptions) { neoMessageSceneCheckbox.state = options.neoMessageSceneEnabled ? .on : .off neoMessageSpeedFactorField.stringValue = Self.format(options.neoMessageSpeedFactor) + nameField.stringValue = options.name pendingLines = options.neoMessageLines + cmdSceneCheckbox.state = options.cmdSceneEnabled ? .on : .off + commandsTextView.string = options.commands.joined(separator: "\n") numberSceneCheckbox.state = options.numberSceneEnabled ? .on : .off twinkleCheckbox.state = options.twinkleEnabled ? .on : .off diffuseCheckbox.state = options.diffuseEnabled ? .on : .off @@ -451,8 +492,13 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, ]) let neoGroup = makeGroupBox(title: "Neo message scene", views: [ makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription), + makeTextSection(title: "Name", field: nameField, description: MatrixScreenSaverOptions.nameDescription, fieldWidth: Self.textFieldWidth / 2), makeNeoMessageLinesButtonRow(), ]) + let cmdGroup = makeGroupBox(title: "cmd capture scene", views: [ + makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), + makeMultilineTextSection(title: "Commands", textView: commandsTextView, scrollView: commandsScrollView, description: MatrixScreenSaverOptions.commandsDescription), + ]) let numberGroup = makeGroupBox(title: "Number scene", views: [ makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription), ]) @@ -466,11 +512,12 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, ]) rootStack.addArrangedSubview(generalGroup) rootStack.addArrangedSubview(neoGroup) + rootStack.addArrangedSubview(cmdGroup) rootStack.addArrangedSubview(numberGroup) rootStack.addArrangedSubview(rainGroup) let groupWidth = -(rootStack.edgeInsets.left + rootStack.edgeInsets.right) - for group in [generalGroup, neoGroup, numberGroup, rainGroup] { + for group in [generalGroup, neoGroup, cmdGroup, numberGroup, rainGroup] { group.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth).isActive = true } @@ -692,7 +739,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .map { String($0.prefix(MatrixScreenSaverOptions.maxNeoMessageLineLength)) } - if pendingLines.isEmpty { pendingLines = MatrixScreenSaverOptions.defaultNeoMessageLines } window.endSheet(linesEditorWindow ?? window) } @@ -977,6 +1023,37 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds a labeled multi-line text input (one entry per line) with its description. + private func makeMultilineTextSection(title: String, textView: NSTextView, scrollView: NSScrollView, description: String) -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let label = NSTextField(labelWithString: title) + label.font = .systemFont(ofSize: 13, weight: .semibold) + + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.isRichText = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.textContainerInset = NSSize(width: 6, height: 6) + + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.widthAnchor.constraint(equalToConstant: Self.textFieldWidth * 1.6).isActive = true + scrollView.heightAnchor.constraint(equalToConstant: 90).isActive = true + + stack.addArrangedSubview(label) + stack.addArrangedSubview(scrollView) + stack.addArrangedSubview(makeDescriptionLabel(description)) + return stack + } + /// Builds the rain duration section with "Run forever" checkbox and optional seconds field. private func makeRainDurationSection() -> NSView { let stack = NSStackView() @@ -1120,12 +1197,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.trimmingCharacters(in: .whitespacesAndNewlines), + neoMessageLines: pendingLines, + cmdSceneEnabled: cmdSceneCheckbox.state == .on, + commands: commandsTextView.string + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, 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 7d369ec..067e332 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -378,6 +378,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 diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 304771f..11d0061 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -4,6 +4,8 @@ final class NativeMatrixRenderer { struct Configuration: Equatable { var neoMessageSceneEnabled = true var neoMessageSpeedFactor = 1.0 + var cmdSceneEnabled = false + var commands: [String] = [] var numberSceneEnabled = true var twinkleEnabled = true var diffuseEnabled = true @@ -13,6 +15,7 @@ final class NativeMatrixRenderer { var frameRate = 25.0 var errorRate = 1.0 var characters = "" + var neoMessageName = "" var neoMessageLines: [String] = NeoMessageScene.defaultLines /// When true the 10-second multi-screen sync delay is skipped so scenes /// start within one second of launch. Set for preview contexts where @@ -24,6 +27,10 @@ final class NativeMatrixRenderer { Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), + cmdSceneEnabled: cmdSceneEnabled, + commands: commands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -33,10 +40,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 +105,7 @@ final class NativeMatrixRenderer { private enum Scene { case neoMessage + case cmdCapture case numberIntro case rainForever } @@ -266,6 +283,9 @@ final class NativeMatrixRenderer { } private var neoScene = NeoMessageScene() + private var cmdScene = CmdCaptureScene() + private var cmdCaptureToken = UUID() + 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 +302,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 @@ -407,6 +428,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 +450,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: transitionFromNeoMessage() + case .cmdCapture: + transitionFromCmdCapture() case .numberIntro: beginRainScene() case .rainForever: @@ -465,6 +496,8 @@ final class NativeMatrixRenderer { columns > 0 && rows > 0 && (previousConfiguration.neoMessageSceneEnabled != sanitizedConfiguration.neoMessageSceneEnabled || + previousConfiguration.cmdSceneEnabled != sanitizedConfiguration.cmdSceneEnabled || + previousConfiguration.commands != sanitizedConfiguration.commands || previousConfiguration.numberSceneEnabled != sanitizedConfiguration.numberSceneEnabled || previousConfiguration.neoMessageLines != sanitizedConfiguration.neoMessageLines) @@ -509,6 +542,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: stepNeoMessageFrame() + case .cmdCapture: + stepCmdCaptureFrame() case .numberIntro: stepNumberIntroScene() case .rainForever: @@ -574,6 +609,81 @@ final class NativeMatrixRenderer { // MARK: - Number intro scene + /// 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 + } + + /// Skips the "Call trans opt / Trace program" typed lines and drops straight into the + /// number-rain trace, using whatever is already on screen (the held cmd output) as the + /// starting frame so it reads as a dissolve rather than a hard cut. + private func beginNumberRainFromCmd() { + activeScene = .numberIntro + numberSceneRNG = Xorshift64(seed: sceneSeed &+ 2) + numberIntroAnchor = Date.timeIntervalSinceReferenceDate + numberIntroSchedule = NumberIntroSchedule( + typingText: "", + cursorBlinkEnd: 0, + charTimings: [], + typingEnd: 0, + pause1Duration: 0, + pause1End: 0, + pause2Duration: 0, + rainStart: 0, + blackoutRounds: computeBlackoutRounds(rng: &numberSceneRNG) + ) + numberIntroPhase = .rain + numberIntroRainFrame = 0 + numberIntroBlackoutCount = 0 + numberIntroBlackedColumns = [] + dissolveCmdOutputIntoNumberRain() + markAllRowsDirty() + } + + /// Replaces the currently-visible cmd output glyphs with random digits in place, and + /// fills the remaining blank content rows with random digits — a single-frame "dissolve" + /// from the captured text into the number rain that follows. Cells that held visible + /// cmd text stay at full brightness/bold so the previous text shape briefly reads + /// through the new digits. + private func dissolveCmdOutputIntoNumberRain() { + let m = Self.numberIntroMargin + guard rows > m * 2, columns > m * 2 else { return } + let levelCount = max(levelColors.count, 1) + let rowStart = m; let rowEnd = rows - m + let colStart = m; let colEnd = columns - m + for row in rowStart.. 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,12 +748,12 @@ 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 } } @@ -768,6 +860,27 @@ final class NativeMatrixRenderer { visibleCountByRow[m] = col - colStart } + /// Writes plain text into an arbitrary content row, respecting the shared margin. + /// Unlike `setRow0` this takes no cursor/typed-count animation state — used for + /// instantly revealing a full line, such as a captured cmd output row. + private func setRow(_ row: Int, text: String) { + let m = Self.numberIntroMargin + guard row >= 0, row < rows, columns > m else { return } + let baseGreenLevel = max(levelColors.count / 2, 1) + let scalars = Array(text.unicodeScalars) + 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. @@ -898,7 +1021,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,6 +1032,81 @@ final class NativeMatrixRenderer { } } + /// Moves from the cmd capture scene to the next scene in the sequence. Skips the typed + /// "Call trans opt / Trace program" lines and dissolves the cmd output straight into the + /// number-rain trace instead, since by this point the operator has already read the + /// captured output. + private func transitionFromCmdCapture() { + if configuration.numberSceneEnabled { + beginNumberRainFromCmd() + } else { + beginRainScene() + stepRainFrame() + } + } + + /// Initializes the cmd capture scene and launches the configured command + /// on a background queue. A stale-token guard discards results from a + /// previous launch if the scene sequence restarts before the command exits. + private func beginCmdCaptureScene() { + activeScene = .cmdCapture + for i in renderCells.indices { renderCells[i] = Self.blankRenderCell } + for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } + markAllRowsDirty() + + // Pick one command from the configured list using a seed derived from the shared + // scene seed, so every display in a multi-monitor setup picks the same command, + // while a fresh restart cycle (new sceneSeed) is free to pick a different one. + // 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 command = configuration.commands.randomElement(using: &commandPickerRNG) ?? "" + + cmdScene.reset(startTime: sceneStartTime, command: command) + guard cmdScene.phase != .done else { return } + + let token = UUID() + cmdCaptureToken = token + let m = Self.numberIntroMargin + // Content rows run m+1.. 0, rows > 0 else { if configuration.neoMessageSceneEnabled { activeScene = .neoMessage + } else if configuration.shouldRunCmdScene { + activeScene = .cmdCapture } else if configuration.numberSceneEnabled { activeScene = .numberIntro } else { @@ -946,6 +1148,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/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift new file mode 100644 index 0000000..26e7b3b --- /dev/null +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -0,0 +1,40 @@ +import Foundation + +/// 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") + + // CmdCaptureScene + 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 == .launching, "CmdCaptureScene: command resets to launching phase") + ok(scene.scheduledDuration == nil, "CmdCaptureScene: scheduledDuration is nil while launching") + + scene.receiveCapture("line 1\nline 2\nline 3\n", maxLines: 2, maxColumns: 50, now: 101.0) + ok(scene.phase == .revealing, "CmdCaptureScene: receiveCapture transitions to revealing phase") + ok(scene.lines.count == 2, "CmdCaptureScene: receiveCapture caps lines to maxLines") + ok(scene.lines == ["line 1", "line 2"], "CmdCaptureScene: receiveCapture parses output correctly") + ok(scene.scheduledDuration != nil, "CmdCaptureScene: scheduledDuration is populated after capture") + + var emptyOutputScene = CmdCaptureScene() + emptyOutputScene.reset(startTime: 100.0, command: "true") + emptyOutputScene.receiveCapture("", maxLines: 10, maxColumns: 50, now: 101.0) + ok(emptyOutputScene.lines == ["(no output)"], "CmdCaptureScene: empty output falls back to placeholder") + + // Advance past reveal and hold + let totalDuration = scene.scheduledDuration ?? 0 + scene.advance(now: 100.0 + totalDuration + 1.0) + ok(scene.phase == .done, "CmdCaptureScene: advance past hold duration transitions to done") +} 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/build-wallpaper.sh b/build-wallpaper.sh index c189c90..4695233 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 466d222..4dda8e0 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" \ From dc86e2269c086ff0b872d5a217eec682eceeaf1d Mon Sep 17 00:00:00 2001 From: John Doll Date: Fri, 23 Oct 2026 20:32:08 -0500 Subject: [PATCH 02/10] feat: add command capture scene and customizable Neo intro name - Add CmdCaptureScene to execute background shell commands and reveal stdout before the trace program - Add configurable settings for command capture list and Neo intro name in options sheet - Add unit tests for CmdCaptureScene lifecycle and NeoMessageScene name templating - Update build and test scripts for saver and wallpaper targets --- AGENTS.md | 3 + README.md | 8 +- .../MatrixScreenSaver/CmdCaptureScene.swift | 235 ++++++++++++++++ .../MatrixScreenSaverOptions.swift | 96 ++++++- .../MatrixScreenSaverView.swift | 4 + .../NativeMatrixRenderer.swift | 258 ++++++++++++++++-- .../MatrixScreenSaver/NeoMessageScene.swift | 23 +- .../CmdCaptureSceneTests.swift | 40 +++ Tests/MatrixScreenSaverTests/main.swift | 1 + Tests/MatrixWallpaperTests/ConsumerHost.swift | 1 + build-wallpaper.sh | 1 + build.sh | 1 + tests.sh | 2 + wallpaper-tests.sh | 1 + 14 files changed, 631 insertions(+), 43 deletions(-) create mode 100644 Sources/MatrixScreenSaver/CmdCaptureScene.swift create mode 100644 Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift diff --git a/AGENTS.md b/AGENTS.md index 9677236..de6258f 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 @@ -58,6 +60,7 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature | `install.sh` | Builds and installs to `~/Library/Screen Savers/`, kills `ScreenSaverEngine` | | `preview.sh` | Builds and launches the saver in the preview host for fast iteration | | `Scripts/install-saver.sh` | Shared installer (strips quarantine, copies with `ditto`, verifies hash) | +| `Scripts/apply-theme.sh` | Applies the full desktop theme (dark mode/accent, wallpaper, iTerm2 profile, Terminal preset) and installs/selects the saver — see `theme/` | ### CI — `.github/workflows/` diff --git a/README.md b/README.md index bca6ea9..f14bd2c 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,10 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | 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 | +| 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)* | +| cmd capture scene | Run a command and reveal its captured stdout before the trace program (Number scene). Runs after the Neo message and before the Number scene. Skipped entirely if disabled or if no commands are configured. | Off | +| cmd commands | One shell command per line. A random one runs each time the screen saver starts (all displays in a multi-monitor setup agree on the same pick). Empty disables the scene. | *(empty)* | | 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) | @@ -57,13 +60,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, cmd capture scene/commands, 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` - runs a command, captures its output, and reveals it before the 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..f505f78 --- /dev/null +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -0,0 +1,235 @@ +import Foundation +import Darwin + +/// The drawing state snapshot the view uses to render the current cmd capture frame. +struct CmdCaptureRenderState { + let lines: [String] + let visibleLineCount: Int + let statusLine: String? +} + +/// Time-driven state machine that runs an external command in the background, +/// captures its stdout, and reveals the captured lines a row at a time before +/// handing off to the next scene (the trace program / number intro). +struct CmdCaptureScene { + enum Phase: Equatable { + case launching + case revealing + case holding + case done + } + + static let linesPerSecond: Double = 14.0 + static let holdDuration: TimeInterval = 2.5 + static let captureTimeout: TimeInterval = 12.0 + static let statusLineText = "Querying..." + + private(set) var phase: Phase = .done + private(set) var visibleLineCount: Int = 0 + private(set) var lines: [String] = [] + + private var startTime: TimeInterval = 0 + private var captureElapsed: TimeInterval? + private var revealEndElapsed: TimeInterval = 0 + private var holdEndElapsed: TimeInterval = 0 + + /// Wall-clock duration of the scene once capture completes; `nil` while still launching. + var scheduledDuration: TimeInterval? { + captureElapsed == nil ? nil : holdEndElapsed + } + + /// Resets the scene for a new session. Does not launch anything itself — callers run + /// `command` (e.g. via ``captureOutput(command:timeout:)`` on a background queue) and + /// report the result back through ``receiveCapture(_:maxLines:maxColumns:now:)``. + /// + /// - Parameters: + /// - startTime: Reference-relative wall-clock time the scene begins. + /// - command: The command that will be run. Empty skips straight to `.done`. + mutating func reset(startTime: TimeInterval, command: String) { + self.startTime = startTime + lines = [] + visibleLineCount = 0 + captureElapsed = nil + revealEndElapsed = 0 + holdEndElapsed = 0 + phase = command.isEmpty ? .done : .launching + } + + /// Records the captured output once the background command finishes. + /// `now` is the same reference-relative clock used by `advance(now:)`. + mutating func receiveCapture(_ output: String, maxLines: Int, maxColumns: Int, now: TimeInterval) { + guard phase == .launching else { return } + let trimmedLines = output + .split(separator: "\n", omittingEmptySubsequences: false) + .map { String($0.prefix(max(maxColumns, 1))) } + .filter { !$0.isEmpty } + lines = trimmedLines.isEmpty ? ["(no output)"] : Array(trimmedLines.prefix(max(maxLines, 1))) + + let elapsed = max(now - startTime, 0) + captureElapsed = elapsed + let revealDuration = Double(lines.count) / Self.linesPerSecond + revealEndElapsed = elapsed + revealDuration + holdEndElapsed = revealEndElapsed + Self.holdDuration + phase = .revealing + } + + mutating func advance(now: TimeInterval) { + guard phase != .done else { return } + guard let captureElapsed else { return } // still launching; waiting on receiveCapture + let sceneTime = now - startTime + + if sceneTime < captureElapsed { + visibleLineCount = 0 + phase = .revealing + } else if sceneTime < revealEndElapsed { + let revealElapsed = sceneTime - captureElapsed + visibleLineCount = min(lines.count, Int(revealElapsed * Self.linesPerSecond) + 1) + phase = .revealing + } else if sceneTime < holdEndElapsed { + visibleLineCount = lines.count + phase = .holding + } else { + visibleLineCount = lines.count + phase = .done + } + } + + var renderState: CmdCaptureRenderState { + switch phase { + case .done: + return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: nil) + case .launching: + return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: Self.statusLineText) + case .revealing, .holding: + return CmdCaptureRenderState(lines: lines, visibleLineCount: visibleLineCount, statusLine: nil) + } + } + + /// Runs `command` via `/bin/sh -c` on the calling thread (intended to be a background + /// queue) and returns captured stdout, or an empty string if the command fails, is + /// missing, or exceeds `timeout`. Reads both pipes concurrently with process execution + /// to avoid the classic `Process`/`Pipe` deadlock on large output. stderr is always + /// logged via `NSLog` for diagnostics, and is used as a fallback result when stdout is + /// empty so a failing command (e.g. a sandboxed screen saver process unable to reach a + /// tool's usual environment) reads its failure reason on screen instead of just + /// "(no output)". + /// Common Homebrew/system binary directories, prepended to `PATH` so commands resolve + /// the same way they would from an interactive shell. The screen saver host process + /// (`legacyScreenSaver.appex`) is sandboxed and its inherited `PATH` typically omits + /// these, so tools like `kubectl` installed under `/usr/local/bin` or + /// `/opt/homebrew/bin` otherwise fail with "command not found". + private static let extraPathDirectories = [ + "/opt/homebrew/bin", "/opt/homebrew/sbin", + "/usr/local/bin", "/usr/local/sbin", + "/usr/bin", "/bin", "/usr/sbin", "/sbin", + ] + + /// The real user home directory, read from the system password database rather than + /// the `HOME` environment variable. App Sandbox redirects `HOME` for the (sandboxed) + /// screen saver host process to a container path, which breaks tools that locate their + /// config relative to home (e.g. `kubectl` looking for `~/.kube/config`). + private static var realHomeDirectory: String? { + guard let pw = getpwuid(getuid()), let dir = pw.pointee.pw_dir else { return nil } + return String(cString: dir) + } + + static func captureOutput(command: String, timeout: TimeInterval = captureTimeout) -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + + var environment = ProcessInfo.processInfo.environment + let existingPath = environment["PATH"] ?? "" + let existingComponents = Set(existingPath.split(separator: ":").map(String.init)) + let mergedPath = (extraPathDirectories.filter { !existingComponents.contains($0) } + [existingPath]) + .filter { !$0.isEmpty } + .joined(separator: ":") + environment["PATH"] = mergedPath + if let realHome = realHomeDirectory { + environment["HOME"] = realHome + } + process.environment = environment + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + } catch { + NSLog("MatrixScreenSaver cmd capture failed to launch '%@': %@", command, error.localizedDescription) + return "" + } + + var capturedOutput = Data() + var capturedError = Data() + let readQueue = DispatchQueue(label: "dev.patsch.MatrixScreenSaver.cmdCapture.read", attributes: .concurrent) + let outputDone = DispatchSemaphore(value: 0) + let errorDone = DispatchSemaphore(value: 0) + readQueue.async { + capturedOutput = outputPipe.fileHandleForReading.readDataToEndOfFile() + outputDone.signal() + } + readQueue.async { + capturedError = errorPipe.fileHandleForReading.readDataToEndOfFile() + errorDone.signal() + } + + let deadline = DispatchTime.now() + timeout + guard outputDone.wait(timeout: deadline) == .success, + errorDone.wait(timeout: deadline) == .success else { + process.terminate() + NSLog("MatrixScreenSaver cmd capture timed out after %.1fs: '%@'", timeout, command) + return "" + } + process.waitUntilExit() + + let stdout = String(data: capturedOutput, encoding: .utf8) ?? "" + let stderr = String(data: capturedError, encoding: .utf8) ?? "" + logCapture(command: command, exitCode: process.terminationStatus, stdout: stdout, stderr: stderr, environment: environment) + if !stderr.isEmpty { + NSLog("MatrixScreenSaver cmd capture stderr for '%@' (exit %d): %@", command, process.terminationStatus, stderr) + } + if stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !stderr.isEmpty { + return stderr + } + return stdout + } + + /// Appends a full record of a capture attempt (command, exit code, PATH/HOME used, + /// stdout, stderr) to a plain-text log file under the process's Logs directory. + /// Exists purely for troubleshooting sandboxed-environment failures (missing PATH + /// entries, redirected HOME, network restrictions) from outside the sandbox, since the + /// on-screen output is otherwise the only place this ever surfaces. + private static func logCapture(command: String, exitCode: Int32, stdout: String, stderr: String, environment: [String: String]) { + guard let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? + .appendingPathComponent("Logs", isDirectory: true) else { return } + try? FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) + let logFile = logsDir.appendingPathComponent("MatrixScreenSaver-cmd-debug.log") + + let entry = """ + --- + \(Date()) + command: \(command) + exitCode: \(exitCode) + PATH: \(environment["PATH"] ?? "") + HOME: \(environment["HOME"] ?? "") + stdout (\(stdout.utf8.count) bytes): + \(stdout) + stderr (\(stderr.utf8.count) bytes): + \(stderr) + + """ + + if let data = entry.data(using: .utf8) { + if let handle = try? FileHandle(forWritingTo: logFile) { + handle.seekToEndOfFile() + handle.write(data) + try? handle.close() + } else { + try? data.write(to: logFile) + } + } + } +} diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index b465a3c..a12b749 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -5,7 +5,10 @@ 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" + static let commands = "Commands" static let numberSceneEnabled = "NumberSceneEnabled" static let twinkleEnabled = "TwinkleEnabled" static let diffuseEnabled = "DiffuseEnabled" @@ -23,7 +26,10 @@ 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 + static let defaultCommands: [String] = [] static let defaultNumberSceneEnabled = true static let defaultTwinkleEnabled = true static let defaultDiffuseEnabled = true @@ -50,10 +56,15 @@ 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 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 = "Run a command and reveal its captured output before the trace program. Off by default — most installs won't have a command configured." + static let commandsDescription = "One shell command per line. A random one runs each time the screen saver starts. Empty disables the scene." static let numberSceneDescription = "Show the startup number scene. On by default." static let diffuseDescription = "On by default." static let twinkleDescription = "On by default." @@ -73,7 +84,10 @@ struct MatrixScreenSaverOptions: Equatable { var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled var neoMessageSpeedFactor = defaultNeoMessageSpeedFactor + var name = defaultName var neoMessageLines = defaultNeoMessageLines + var cmdSceneEnabled = defaultCmdSceneEnabled + var commands = defaultCommands var numberSceneEnabled = defaultNumberSceneEnabled var twinkleEnabled = defaultTwinkleEnabled var diffuseEnabled = defaultDiffuseEnabled @@ -105,6 +119,8 @@ struct MatrixScreenSaverOptions: Equatable { NativeMatrixRenderer.Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: neoMessageSpeedFactor, + cmdSceneEnabled: cmdSceneEnabled, + commands: commands, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -114,6 +130,7 @@ struct MatrixScreenSaverOptions: Equatable { frameRate: frameRate, errorRate: errorRate, characters: characters, + neoMessageName: name, neoMessageLines: neoMessageLines, skipSyncDelay: isPreview ) @@ -126,13 +143,21 @@ 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) } + .map { String($0.prefix(Self.maxCommandLength)) } + .filter { !$0.isEmpty } + .prefix(Self.maxCommandCount) + .map { $0 } return MatrixScreenSaverOptions( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, Self.minimumNeoMessageSpeedFactor), + name: name.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: sanitizedLines, + cmdSceneEnabled: cmdSceneEnabled, + commands: sanitizedCommands, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -167,7 +192,10 @@ struct MatrixScreenSaverOptions: Equatable { defaults.register(defaults: [ Keys.neoMessageSceneEnabled: defaultNeoMessageSceneEnabled, Keys.neoMessageSpeedFactor: defaultNeoMessageSpeedFactor, + Keys.name: defaultName, Keys.neoMessageLines: defaultNeoMessageLines, + Keys.cmdSceneEnabled: defaultCmdSceneEnabled, + Keys.commands: defaultCommands, Keys.numberSceneEnabled: defaultNumberSceneEnabled, Keys.twinkleEnabled: defaultTwinkleEnabled, Keys.diffuseEnabled: defaultDiffuseEnabled, @@ -189,7 +217,10 @@ 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), + commands: (defaults.array(forKey: Keys.commands) as? [String]) ?? defaultCommands, numberSceneEnabled: defaults.bool(forKey: Keys.numberSceneEnabled), twinkleEnabled: defaults.bool(forKey: Keys.twinkleEnabled), diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), @@ -211,7 +242,10 @@ 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.commands, forKey: Keys.commands) defaults.set(options.numberSceneEnabled, forKey: Keys.numberSceneEnabled) defaults.set(options.twinkleEnabled, forKey: Keys.twinkleEnabled) defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) @@ -263,6 +297,7 @@ 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 nameField = NSTextField(string: "") private let linesTableView = NSTableView() private let linesScrollView = NSScrollView() private var editorLines: [String] = [] @@ -275,6 +310,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private static let removeColumnID = NSUserInterfaceItemIdentifier("remove") private var pendingLines: [String] = MatrixScreenSaverOptions.defaultNeoMessageLines private var linesEditorWindow: NSWindow? + private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Show command capture scene", target: nil, action: nil) + private let commandsTextView = NSTextView() + private let commandsScrollView = NSScrollView() private let numberSceneCheckbox = NSButton(checkboxWithTitle: "Show number 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) @@ -333,7 +371,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, func prepare(using options: MatrixScreenSaverOptions) { neoMessageSceneCheckbox.state = options.neoMessageSceneEnabled ? .on : .off neoMessageSpeedFactorField.stringValue = Self.format(options.neoMessageSpeedFactor) + nameField.stringValue = options.name pendingLines = options.neoMessageLines + cmdSceneCheckbox.state = options.cmdSceneEnabled ? .on : .off + commandsTextView.string = options.commands.joined(separator: "\n") numberSceneCheckbox.state = options.numberSceneEnabled ? .on : .off twinkleCheckbox.state = options.twinkleEnabled ? .on : .off diffuseCheckbox.state = options.diffuseEnabled ? .on : .off @@ -439,10 +480,15 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, ])) tabs.addTabViewItem(makeOptionsTab(title: "Intro scenes", views: [ makeCheckboxSection(checkbox: neoMessageSceneCheckbox, description: MatrixScreenSaverOptions.neoMessageSceneDescription), + makeTextSection(title: "Name", field: nameField, description: MatrixScreenSaverOptions.nameDescription, fieldWidth: Self.textFieldWidth / 2), makeNeoMessageLinesButtonRow(), makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription), makeNumericSection(title: "Typing speed", field: neoMessageSpeedFactorField, description: MatrixScreenSaverOptions.neoMessageSpeedFactorDescription), ])) + tabs.addTabViewItem(makeOptionsTab(title: "Command capture", views: [ + makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), + makeMultilineTextSection(title: "Commands", textView: commandsTextView, scrollView: commandsScrollView, description: MatrixScreenSaverOptions.commandsDescription), + ])) tabs.addTabViewItem(makeOptionsTab(title: "Rain", views: [ makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), @@ -684,7 +730,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } .map { String($0.prefix(MatrixScreenSaverOptions.maxNeoMessageLineLength)) } - if pendingLines.isEmpty { pendingLines = MatrixScreenSaverOptions.defaultNeoMessageLines } window.endSheet(linesEditorWindow ?? window) } @@ -976,6 +1021,37 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds a labeled multi-line text input (one entry per line) with its description. + private func makeMultilineTextSection(title: String, textView: NSTextView, scrollView: NSScrollView, description: String) -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let label = NSTextField(labelWithString: title) + label.font = .systemFont(ofSize: 13, weight: .semibold) + + textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) + textView.isRichText = false + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = false + textView.textContainerInset = NSSize(width: 6, height: 6) + + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.borderType = .bezelBorder + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.widthAnchor.constraint(equalToConstant: Self.textFieldWidth * 1.6).isActive = true + scrollView.heightAnchor.constraint(equalToConstant: 90).isActive = true + + stack.addArrangedSubview(label) + stack.addArrangedSubview(scrollView) + stack.addArrangedSubview(makeDescriptionLabel(description)) + return stack + } + /// Builds the rain duration section with "Run forever" checkbox and optional seconds field. private func makeRainDurationSection() -> NSView { let stack = NSStackView() @@ -1126,12 +1202,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.trimmingCharacters(in: .whitespacesAndNewlines), + neoMessageLines: pendingLines, + cmdSceneEnabled: cmdSceneCheckbox.state == .on, + commands: commandsTextView.string + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, 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..6081135 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -391,6 +391,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 diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 304771f..11d0061 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -4,6 +4,8 @@ final class NativeMatrixRenderer { struct Configuration: Equatable { var neoMessageSceneEnabled = true var neoMessageSpeedFactor = 1.0 + var cmdSceneEnabled = false + var commands: [String] = [] var numberSceneEnabled = true var twinkleEnabled = true var diffuseEnabled = true @@ -13,6 +15,7 @@ final class NativeMatrixRenderer { var frameRate = 25.0 var errorRate = 1.0 var characters = "" + var neoMessageName = "" var neoMessageLines: [String] = NeoMessageScene.defaultLines /// When true the 10-second multi-screen sync delay is skipped so scenes /// start within one second of launch. Set for preview contexts where @@ -24,6 +27,10 @@ final class NativeMatrixRenderer { Configuration( neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), + cmdSceneEnabled: cmdSceneEnabled, + commands: commands + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty }, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -33,10 +40,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 +105,7 @@ final class NativeMatrixRenderer { private enum Scene { case neoMessage + case cmdCapture case numberIntro case rainForever } @@ -266,6 +283,9 @@ final class NativeMatrixRenderer { } private var neoScene = NeoMessageScene() + private var cmdScene = CmdCaptureScene() + private var cmdCaptureToken = UUID() + 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 +302,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 @@ -407,6 +428,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 +450,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: transitionFromNeoMessage() + case .cmdCapture: + transitionFromCmdCapture() case .numberIntro: beginRainScene() case .rainForever: @@ -465,6 +496,8 @@ final class NativeMatrixRenderer { columns > 0 && rows > 0 && (previousConfiguration.neoMessageSceneEnabled != sanitizedConfiguration.neoMessageSceneEnabled || + previousConfiguration.cmdSceneEnabled != sanitizedConfiguration.cmdSceneEnabled || + previousConfiguration.commands != sanitizedConfiguration.commands || previousConfiguration.numberSceneEnabled != sanitizedConfiguration.numberSceneEnabled || previousConfiguration.neoMessageLines != sanitizedConfiguration.neoMessageLines) @@ -509,6 +542,8 @@ final class NativeMatrixRenderer { switch activeScene { case .neoMessage: stepNeoMessageFrame() + case .cmdCapture: + stepCmdCaptureFrame() case .numberIntro: stepNumberIntroScene() case .rainForever: @@ -574,6 +609,81 @@ final class NativeMatrixRenderer { // MARK: - Number intro scene + /// 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 + } + + /// Skips the "Call trans opt / Trace program" typed lines and drops straight into the + /// number-rain trace, using whatever is already on screen (the held cmd output) as the + /// starting frame so it reads as a dissolve rather than a hard cut. + private func beginNumberRainFromCmd() { + activeScene = .numberIntro + numberSceneRNG = Xorshift64(seed: sceneSeed &+ 2) + numberIntroAnchor = Date.timeIntervalSinceReferenceDate + numberIntroSchedule = NumberIntroSchedule( + typingText: "", + cursorBlinkEnd: 0, + charTimings: [], + typingEnd: 0, + pause1Duration: 0, + pause1End: 0, + pause2Duration: 0, + rainStart: 0, + blackoutRounds: computeBlackoutRounds(rng: &numberSceneRNG) + ) + numberIntroPhase = .rain + numberIntroRainFrame = 0 + numberIntroBlackoutCount = 0 + numberIntroBlackedColumns = [] + dissolveCmdOutputIntoNumberRain() + markAllRowsDirty() + } + + /// Replaces the currently-visible cmd output glyphs with random digits in place, and + /// fills the remaining blank content rows with random digits — a single-frame "dissolve" + /// from the captured text into the number rain that follows. Cells that held visible + /// cmd text stay at full brightness/bold so the previous text shape briefly reads + /// through the new digits. + private func dissolveCmdOutputIntoNumberRain() { + let m = Self.numberIntroMargin + guard rows > m * 2, columns > m * 2 else { return } + let levelCount = max(levelColors.count, 1) + let rowStart = m; let rowEnd = rows - m + let colStart = m; let colEnd = columns - m + for row in rowStart.. 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,12 +748,12 @@ 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 } } @@ -768,6 +860,27 @@ final class NativeMatrixRenderer { visibleCountByRow[m] = col - colStart } + /// Writes plain text into an arbitrary content row, respecting the shared margin. + /// Unlike `setRow0` this takes no cursor/typed-count animation state — used for + /// instantly revealing a full line, such as a captured cmd output row. + private func setRow(_ row: Int, text: String) { + let m = Self.numberIntroMargin + guard row >= 0, row < rows, columns > m else { return } + let baseGreenLevel = max(levelColors.count / 2, 1) + let scalars = Array(text.unicodeScalars) + 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. @@ -898,7 +1021,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,6 +1032,81 @@ final class NativeMatrixRenderer { } } + /// Moves from the cmd capture scene to the next scene in the sequence. Skips the typed + /// "Call trans opt / Trace program" lines and dissolves the cmd output straight into the + /// number-rain trace instead, since by this point the operator has already read the + /// captured output. + private func transitionFromCmdCapture() { + if configuration.numberSceneEnabled { + beginNumberRainFromCmd() + } else { + beginRainScene() + stepRainFrame() + } + } + + /// Initializes the cmd capture scene and launches the configured command + /// on a background queue. A stale-token guard discards results from a + /// previous launch if the scene sequence restarts before the command exits. + private func beginCmdCaptureScene() { + activeScene = .cmdCapture + for i in renderCells.indices { renderCells[i] = Self.blankRenderCell } + for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } + markAllRowsDirty() + + // Pick one command from the configured list using a seed derived from the shared + // scene seed, so every display in a multi-monitor setup picks the same command, + // while a fresh restart cycle (new sceneSeed) is free to pick a different one. + // 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 command = configuration.commands.randomElement(using: &commandPickerRNG) ?? "" + + cmdScene.reset(startTime: sceneStartTime, command: command) + guard cmdScene.phase != .done else { return } + + let token = UUID() + cmdCaptureToken = token + let m = Self.numberIntroMargin + // Content rows run m+1.. 0, rows > 0 else { if configuration.neoMessageSceneEnabled { activeScene = .neoMessage + } else if configuration.shouldRunCmdScene { + activeScene = .cmdCapture } else if configuration.numberSceneEnabled { activeScene = .numberIntro } else { @@ -946,6 +1148,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/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift new file mode 100644 index 0000000..26e7b3b --- /dev/null +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -0,0 +1,40 @@ +import Foundation + +/// 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") + + // CmdCaptureScene + 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 == .launching, "CmdCaptureScene: command resets to launching phase") + ok(scene.scheduledDuration == nil, "CmdCaptureScene: scheduledDuration is nil while launching") + + scene.receiveCapture("line 1\nline 2\nline 3\n", maxLines: 2, maxColumns: 50, now: 101.0) + ok(scene.phase == .revealing, "CmdCaptureScene: receiveCapture transitions to revealing phase") + ok(scene.lines.count == 2, "CmdCaptureScene: receiveCapture caps lines to maxLines") + ok(scene.lines == ["line 1", "line 2"], "CmdCaptureScene: receiveCapture parses output correctly") + ok(scene.scheduledDuration != nil, "CmdCaptureScene: scheduledDuration is populated after capture") + + var emptyOutputScene = CmdCaptureScene() + emptyOutputScene.reset(startTime: 100.0, command: "true") + emptyOutputScene.receiveCapture("", maxLines: 10, maxColumns: 50, now: 101.0) + ok(emptyOutputScene.lines == ["(no output)"], "CmdCaptureScene: empty output falls back to placeholder") + + // Advance past reveal and hold + let totalDuration = scene.scheduledDuration ?? 0 + scene.advance(now: 100.0 + totalDuration + 1.0) + ok(scene.phase == .done, "CmdCaptureScene: advance past hold duration transitions to done") +} 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/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" \ From 00a14abadf3ad641c92b7b93912d5fc969f1f622 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Tue, 27 Oct 2026 17:38:20 +0100 Subject: [PATCH 03/10] fix: bound and cancel command captures - remove sensitive logging and preserve the host environment - bound subprocess lifetime and output, including pipeline cleanup - restrict execution to active playback and preserve paused scene timing - coordinate new options and cover lifecycle and process regressions --- AGENTS.md | 2 - README.md | 6 + .../MatrixScreenSaver/CmdCaptureScene.swift | 307 +++++++++++------- .../MatrixScreenSaverOptions.swift | 11 +- .../NativeMatrixRenderer.swift | 63 ++-- .../CmdCaptureSceneTests.swift | 83 +++++ .../NativeMatrixRendererTests.swift | 74 +++++ .../SharedSaverTests.swift | 13 + 8 files changed, 416 insertions(+), 143 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index de6258f..c41935c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,6 @@ This repository is hosted on GitHub. When relevant, assume GitHub-native feature | `install.sh` | Builds and installs to `~/Library/Screen Savers/`, kills `ScreenSaverEngine` | | `preview.sh` | Builds and launches the saver in the preview host for fast iteration | | `Scripts/install-saver.sh` | Shared installer (strips quarantine, copies with `ditto`, verifies hash) | -| `Scripts/apply-theme.sh` | Applies the full desktop theme (dark mode/accent, wallpaper, iTerm2 profile, Terminal preset) and installs/selects the saver — see `theme/` | ### CI — `.github/workflows/` @@ -192,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/README.md b/README.md index f14bd2c..c161257 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,12 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr | 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) | +Command capture executes your configured commands through `/bin/sh` with the host's permissions. +Only enter commands you trust, and avoid sensitive output: it is visible on the screen saver, +including while the screen is locked. Command text and output are not logged. Each capture has +a 12-second deadline and retains at most 64 KiB per output stream; 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) diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift index f505f78..190544e 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -3,29 +3,42 @@ import Darwin /// The drawing state snapshot the view uses to render the current cmd capture frame. struct CmdCaptureRenderState { + /// Captured rows, already clipped to the display dimensions. let lines: [String] + /// Number of rows currently revealed. let visibleLineCount: Int + /// Launching indicator, absent after capture finishes. let statusLine: String? } -/// Time-driven state machine that runs an external command in the background, -/// captures its stdout, and reveals the captured lines a row at a time before -/// handing off to the next scene (the trace program / number intro). +/// Time-driven reveal and hold sequence for output supplied by a background capture task. struct CmdCaptureScene { + /// Stages of capture presentation. enum Phase: Equatable { + /// Waiting for a background capture result. case launching + /// Showing captured rows progressively. case revealing + /// Keeping all captured rows visible. case holding + /// Finished or skipped because the command was empty. case done } + /// Rows revealed per active second. static let linesPerSecond: Double = 14.0 + /// Seconds the fully revealed output remains visible. static let holdDuration: TimeInterval = 2.5 + /// Maximum command and pipe-completion duration, excluding bounded cleanup. static let captureTimeout: TimeInterval = 12.0 + /// Indicator shown while the background task runs. static let statusLineText = "Querying..." + /// Current presentation stage. private(set) var phase: Phase = .done + /// Number of captured rows revealed so far. private(set) var visibleLineCount: Int = 0 + /// Captured rows clipped to display dimensions. private(set) var lines: [String] = [] private var startTime: TimeInterval = 0 @@ -33,18 +46,18 @@ struct CmdCaptureScene { private var revealEndElapsed: TimeInterval = 0 private var holdEndElapsed: TimeInterval = 0 - /// Wall-clock duration of the scene once capture completes; `nil` while still launching. + /// Active duration of the scene; `nil` while still launching. var scheduledDuration: TimeInterval? { captureElapsed == nil ? nil : holdEndElapsed } - /// Resets the scene for a new session. Does not launch anything itself — callers run - /// `command` (e.g. via ``captureOutput(command:timeout:)`` on a background queue) and - /// report the result back through ``receiveCapture(_:maxLines:maxColumns:now:)``. + /// Resets presentation without launching a command. + /// Call ``CmdCaptureTask/captureOutput(command:timeout:)`` on a background queue, + /// then deliver its result through ``receiveCapture(_:maxLines:maxColumns:now:)``. /// /// - Parameters: - /// - startTime: Reference-relative wall-clock time the scene begins. - /// - command: The command that will be run. Empty skips straight to `.done`. + /// - startTime: Reference-relative time the scene begins. + /// - command: Command to capture. Empty skips straight to `.done`. mutating func reset(startTime: TimeInterval, command: String) { self.startTime = startTime lines = [] @@ -55,15 +68,20 @@ struct CmdCaptureScene { phase = command.isEmpty ? .done : .launching } - /// Records the captured output once the background command finishes. - /// `now` is the same reference-relative clock used by `advance(now:)`. + /// Records a capture result and schedules its reveal and hold. + /// + /// - Parameters: + /// - output: Captured command output. + /// - maxLines: Maximum retained nonempty rows, clamped to at least one. + /// - maxColumns: Maximum characters per row, clamped to at least one. + /// - now: Same reference-relative clock used by ``advance(now:)``. mutating func receiveCapture(_ output: String, maxLines: Int, maxColumns: Int, now: TimeInterval) { guard phase == .launching else { return } let trimmedLines = output - .split(separator: "\n", omittingEmptySubsequences: false) + .split(separator: "\n", maxSplits: max(maxLines, 1), omittingEmptySubsequences: true) + .prefix(max(maxLines, 1)) .map { String($0.prefix(max(maxColumns, 1))) } - .filter { !$0.isEmpty } - lines = trimmedLines.isEmpty ? ["(no output)"] : Array(trimmedLines.prefix(max(maxLines, 1))) + lines = trimmedLines.isEmpty ? ["(no output)"] : trimmedLines let elapsed = max(now - startTime, 0) captureElapsed = elapsed @@ -73,9 +91,19 @@ struct CmdCaptureScene { phase = .revealing } + /// Excludes a suspended interval without rebuilding the reveal schedule. + /// + /// - Parameter duration: Seconds during which animation was paused. + mutating func shiftTimeline(by duration: TimeInterval) { + startTime += duration + } + + /// Updates the reveal stage using the scene's reference-relative clock. + /// + /// - Parameter now: Current reference-relative time in seconds. mutating func advance(now: TimeInterval) { guard phase != .done else { return } - guard let captureElapsed else { return } // still launching; waiting on receiveCapture + guard let captureElapsed else { return } let sceneTime = now - startTime if sceneTime < captureElapsed { @@ -94,6 +122,7 @@ struct CmdCaptureScene { } } + /// Snapshot for the native renderer at the current presentation stage. var renderState: CmdCaptureRenderState { switch phase { case .done: @@ -104,131 +133,177 @@ struct CmdCaptureScene { return CmdCaptureRenderState(lines: lines, visibleLineCount: visibleLineCount, statusLine: nil) } } +} - /// Runs `command` via `/bin/sh -c` on the calling thread (intended to be a background - /// queue) and returns captured stdout, or an empty string if the command fails, is - /// missing, or exceeds `timeout`. Reads both pipes concurrently with process execution - /// to avoid the classic `Process`/`Pipe` deadlock on large output. stderr is always - /// logged via `NSLog` for diagnostics, and is used as a fallback result when stdout is - /// empty so a failing command (e.g. a sandboxed screen saver process unable to reach a - /// tool's usual environment) reads its failure reason on screen instead of just - /// "(no output)". - /// Common Homebrew/system binary directories, prepended to `PATH` so commands resolve - /// the same way they would from an interactive shell. The screen saver host process - /// (`legacyScreenSaver.appex`) is sandboxed and its inherited `PATH` typically omits - /// these, so tools like `kubectl` installed under `/usr/local/bin` or - /// `/opt/homebrew/bin` otherwise fail with "command not found". +/// 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 static let byteLimit = 64 * 1024 private static let extraPathDirectories = [ - "/opt/homebrew/bin", "/opt/homebrew/sbin", - "/usr/local/bin", "/usr/local/sbin", + "/opt/homebrew/bin", "/opt/homebrew/sbin", "/usr/local/bin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", ] - /// The real user home directory, read from the system password database rather than - /// the `HOME` environment variable. App Sandbox redirects `HOME` for the (sandboxed) - /// screen saver host process to a container path, which breaks tools that locate their - /// config relative to home (e.g. `kubectl` looking for `~/.kube/config`). - private static var realHomeDirectory: String? { - guard let pw = getpwuid(getuid()), let dir = pw.pointee.pw_dir else { return nil } - return String(cString: dir) + /// 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 } - static func captureOutput(command: String, timeout: TimeInterval = captureTimeout) -> String { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", command] + /// Executes `/bin/sh -c` on the calling background thread, once per task. + /// Retains at most 64 KiB per stream while draining excess bytes. One monotonic + /// deadline covers both child exit and pipe EOF, followed by TERM/KILL cleanup. + /// 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 in seconds; invalid values prevent execution. + /// - Returns: UTF-8 stdout, or stderr when stdout is blank. Empty on cancellation, + /// timeout, invalid UTF-8, launch failure, or repeated use. Nonzero exit may still yield output. + func captureOutput(command: String, timeout: TimeInterval = CmdCaptureScene.captureTimeout) -> String { + guard !command.isEmpty, !command.utf8.contains(0), timeout.isFinite, timeout > 0 else { return "" } + let deadline = ProcessInfo.processInfo.systemUptime + timeout + guard var outputPipe = Self.makePipe() else { return "" } + defer { outputPipe.forEach { close($0) } } + guard var errorPipe = Self.makePipe() else { return "" } + defer { errorPipe.forEach { close($0) } } + + 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_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) == 0, + posix_spawn_file_actions_adddup2(&actions, outputPipe[1], STDOUT_FILENO) == 0, + posix_spawn_file_actions_adddup2(&actions, errorPipe[1], STDERR_FILENO) == 0 else { return "" } var environment = ProcessInfo.processInfo.environment let existingPath = environment["PATH"] ?? "" let existingComponents = Set(existingPath.split(separator: ":").map(String.init)) - let mergedPath = (extraPathDirectories.filter { !existingComponents.contains($0) } + [existingPath]) - .filter { !$0.isEmpty } - .joined(separator: ":") - environment["PATH"] = mergedPath - if let realHome = realHomeDirectory { - environment["HOME"] = realHome - } - process.environment = environment - - let outputPipe = Pipe() - let errorPipe = Pipe() - process.standardOutput = outputPipe - process.standardError = errorPipe + environment["PATH"] = (Self.extraPathDirectories.filter { !existingComponents.contains($0) } + [existingPath]) + .filter { !$0.isEmpty }.joined(separator: ":") + let arguments = ["/bin/sh", "-c", command].map { strdup($0) } + let variables = environment.map { strdup("\($0.key)=\($0.value)") } + defer { (arguments + variables).forEach { free($0) } } + var argv = arguments + [nil] + var envp = variables + [nil] + var pid: pid_t = 0 - do { - try process.run() - } catch { - NSLog("MatrixScreenSaver cmd capture failed to launch '%@': %@", command, error.localizedDescription) + // 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 "" } - var capturedOutput = Data() - var capturedError = Data() - let readQueue = DispatchQueue(label: "dev.patsch.MatrixScreenSaver.cmdCapture.read", attributes: .concurrent) - let outputDone = DispatchSemaphore(value: 0) - let errorDone = DispatchSemaphore(value: 0) - readQueue.async { - capturedOutput = outputPipe.fileHandleForReading.readDataToEndOfFile() - outputDone.signal() - } - readQueue.async { - capturedError = errorPipe.fileHandleForReading.readDataToEndOfFile() - errorDone.signal() + // 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 + _ = waitpid(pid, &status, WNOHANG) + exitSource.cancel() + exitSource.setEventHandler {} + reaped.signal() } + close(outputPipe[1]) + close(errorPipe[1]) + outputPipe[1] = -1 + errorPipe[1] = -1 - let deadline = DispatchTime.now() + timeout - guard outputDone.wait(timeout: deadline) == .success, - errorDone.wait(timeout: deadline) == .success else { - process.terminate() - NSLog("MatrixScreenSaver cmd capture timed out after %.1fs: '%@'", timeout, command) - return "" + var stdout = Data() + var stderr = Data() + var outputOpen = true + var errorOpen = true + var completed = false + while !isCancelled && ProcessInfo.processInfo.systemUptime < deadline { + Self.drain(outputPipe[0], into: &stdout, open: &outputOpen) + Self.drain(errorPipe[0], into: &stderr, open: &errorOpen) + 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 && !errorOpen { + completed = true + break + } + var descriptors = [ + pollfd(fd: outputOpen ? outputPipe[0] : -1, events: Int16(POLLIN), revents: 0), + pollfd(fd: errorOpen ? errorPipe[0] : -1, events: Int16(POLLIN), revents: 0), + ] + _ = poll(&descriptors, nfds_t(descriptors.count), 20) } - process.waitUntilExit() - let stdout = String(data: capturedOutput, encoding: .utf8) ?? "" - let stderr = String(data: capturedError, encoding: .utf8) ?? "" - logCapture(command: command, exitCode: process.terminationStatus, stdout: stdout, stderr: stderr, environment: environment) - if !stderr.isEmpty { - NSLog("MatrixScreenSaver cmd capture stderr for '%@' (exit %d): %@", command, process.terminationStatus, stderr) + if !completed { + kill(-pid, SIGTERM) + usleep(100_000) } - if stdout.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !stderr.isEmpty { - return stderr + // 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 completed, !isCancelled else { return "" } + let output = String(data: stdout, encoding: .utf8) ?? "" + let error = String(data: stderr, encoding: .utf8) ?? "" + return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !error.isEmpty ? error : output + } + + /// Creates a pipe above standard descriptors, with a nonblocking reader and close-on-exec endpoints. + /// + /// - Returns: Reader and writer descriptors owned by the caller, or `nil` on setup failure. + private static func makePipe() -> [Int32]? { + var raw: [Int32] = [0, 0] + guard pipe(&raw) == 0 else { return nil } + defer { raw.forEach { close($0) } } + let reader = fcntl(raw[0], F_DUPFD_CLOEXEC, 3) + guard reader >= 0 else { return nil } + let writer = fcntl(raw[1], F_DUPFD_CLOEXEC, 3) + guard writer >= 0 else { close(reader); return nil } + guard fcntl(reader, F_SETFL, O_NONBLOCK) == 0 else { + close(reader) + close(writer) + return nil } - return stdout + return [reader, writer] } - /// Appends a full record of a capture attempt (command, exit code, PATH/HOME used, - /// stdout, stderr) to a plain-text log file under the process's Logs directory. - /// Exists purely for troubleshooting sandboxed-environment failures (missing PATH - /// entries, redirected HOME, network restrictions) from outside the sandbox, since the - /// on-screen output is otherwise the only place this ever surfaces. - private static func logCapture(command: String, exitCode: Int32, stdout: String, stderr: String, environment: [String: String]) { - guard let logsDir = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first? - .appendingPathComponent("Logs", isDirectory: true) else { return } - try? FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) - let logFile = logsDir.appendingPathComponent("MatrixScreenSaver-cmd-debug.log") - - let entry = """ - --- - \(Date()) - command: \(command) - exitCode: \(exitCode) - PATH: \(environment["PATH"] ?? "") - HOME: \(environment["HOME"] ?? "") - stdout (\(stdout.utf8.count) bytes): - \(stdout) - stderr (\(stderr.utf8.count) bytes): - \(stderr) - - """ - - if let data = entry.data(using: .utf8) { - if let handle = try? FileHandle(forWritingTo: logFile) { - handle.seekToEndOfFile() - handle.write(data) - try? handle.close() + /// 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 { - try? data.write(to: logFile) + if count == 0 || (errno != EAGAIN && errno != EINTR) { open = false } + return } } } diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index a12b749..a478396 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -63,8 +63,8 @@ struct MatrixScreenSaverOptions: Equatable { static let neoMessageSpeedFactorDescription = "Speed multiplier for intro scene typing (Neo message and number scene). Default: 1.0." 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 = "Run a command and reveal its captured output before the trace program. Off by default — most installs won't have a command configured." - static let commandsDescription = "One shell command per line. A random one runs each time the screen saver starts. Empty disables the scene." + static let cmdSceneDescription = "Run a trusted shell command before the trace program. Off by default. Commands run with the host's permissions; output is visible even while the screen is locked." + static let commandsDescription = "One shell command per line (up to 20, 512 characters each). Overlong commands are ignored. A random one runs each time the screen saver starts. Empty disables the scene." static let numberSceneDescription = "Show the startup number scene. On by default." static let diffuseDescription = "On by default." static let twinkleDescription = "On by default." @@ -110,6 +110,8 @@ 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 || numberSceneEnabled != previous.numberSceneEnabled || rainRunForever != previous.rainRunForever || rainDurationSeconds != previous.rainDurationSeconds } @@ -147,8 +149,8 @@ struct MatrixScreenSaverOptions: Equatable { }() let sanitizedCommands: [String] = commands .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .map { String($0.prefix(Self.maxCommandLength)) } - .filter { !$0.isEmpty } + // 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( @@ -1037,6 +1039,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false + textView.setAccessibilityLabel(title) textView.textContainerInset = NSSize(width: 6, height: 6) scrollView.documentView = textView diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 11d0061..de5a1bb 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -16,7 +16,7 @@ final class NativeMatrixRenderer { var errorRate = 1.0 var characters = "" var neoMessageName = "" - var neoMessageLines: [String] = NeoMessageScene.defaultLines + 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. @@ -273,7 +273,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] { @@ -284,7 +284,10 @@ final class NativeMatrixRenderer { private var neoScene = NeoMessageScene() private var cmdScene = CmdCaptureScene() - private var cmdCaptureToken = UUID() + /// 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? private let cmdCaptureQueue = DispatchQueue(label: "dev.patsch.MatrixScreenSaver.cmdCapture") private var layers = [Layer(), Layer(), Layer()] private var renderCells: [RenderCell] = [] @@ -398,6 +401,18 @@ 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 } /// Excludes paused wall-clock time while retaining cells, random state, and scene progress. @@ -410,6 +425,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 @@ -865,7 +881,7 @@ final class NativeMatrixRenderer { /// instantly revealing a full line, such as a captured cmd output row. private func setRow(_ row: Int, text: String) { let m = Self.numberIntroMargin - guard row >= 0, row < rows, columns > m else { return } + guard row >= 0, row < rows, columns > m * 2 else { return } let baseGreenLevel = max(levelColors.count / 2, 1) let scalars = Array(text.unicodeScalars) let offset = row * columns @@ -1045,11 +1061,11 @@ final class NativeMatrixRenderer { } } - /// Initializes the cmd capture scene and launches the configured command - /// on a background queue. A stale-token guard discards results from a - /// previous launch if the scene sequence restarts before the command exits. + /// Launches one cancellable capture only when this scene enters active playback. 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() @@ -1063,30 +1079,31 @@ final class NativeMatrixRenderer { var commandPickerRNG = Xorshift64(seed: Self.mixSeed(sceneSeed &+ 5)) let command = configuration.commands.randomElement(using: &commandPickerRNG) ?? "" - cmdScene.reset(startTime: sceneStartTime, command: command) + cmdScene.reset(startTime: Date.timeIntervalSinceReferenceDate, command: command) guard cmdScene.phase != .done else { return } - let token = UUID() - cmdCaptureToken = token - let m = Self.numberIntroMargin - // Content rows run m+1.. '\(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\n", "CmdCaptureTask: stdout takes priority") + ok(CmdCaptureTask().captureOutput(command: "printf ' '; printf 'diagnostic' >&2; exit 1") == "diagnostic", "CmdCaptureTask: stderr fallback survives nonzero exit") + ok(CmdCaptureTask().captureOutput(command: "printf '%s' \"$HOME\"") == (ProcessInfo.processInfo.environment["HOME"] ?? ""), "CmdCaptureTask: inherits host HOME") + + 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" }, "CmdCaptureTask: drains simultaneous streams with bounded retained 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 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 running = CmdCaptureTask() + let finished = DispatchSemaphore(value: 0) + let command = "trap '' TERM; (trap '' TERM; printf '%s' $$ > '\(ready)'; /bin/sleep 0.7; printf escaped > '\(late)') | /bin/cat; wait" + DispatchQueue.global().async { + _ = running.captureOutput(command: command, timeout: 3) + finished.signal() + } + let readyDeadline = ProcessInfo.processInfo.systemUptime + 2 + while !FileManager.default.fileExists(atPath: ready) && ProcessInfo.processInfo.systemUptime < readyDeadline { + Thread.sleep(forTimeInterval: 0.01) + } + ok(FileManager.default.fileExists(atPath: ready), "CmdCaptureTask: descendant pipeline starts") + running.cancel() + ok(finished.wait(timeout: .now() + 1.5) == .success, "CmdCaptureTask: cancellation of TERM-refusing pipeline is bounded") + let pidText = (try? String(contentsOfFile: ready, encoding: .utf8)) ?? "" + let pid = Int32(pidText) ?? 0 + 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..14a5a88 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -1,8 +1,13 @@ import Foundation func nativeMatrixRendererTests() { + commandCaptureLifecycleTests() 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 +128,72 @@ 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") } + +/// 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.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() + 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() + let row = NativeMatrixRenderer.contentMargin + 1 + 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: completion during suspension gets its full reveal interval on resume") + renderer.stop() +} diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 46ef4c6..ef7acb9 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -91,6 +91,10 @@ func sharedSaverTests() { 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") var live = original live.twinkleEnabled.toggle() live.diffuseEnabled.toggle() @@ -107,6 +111,9 @@ 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"] }), ("number intro", { $0.numberSceneEnabled.toggle() }), ("rain cycle mode", { $0.rainRunForever.toggle() }), ("rain cycle duration", { $0.rainDurationSeconds += 1 }), @@ -116,6 +123,12 @@ 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") } /// Checks raster reuse through layout and option updates, retaining images to compare identity safely. From 799abf13578669e349b68f25f639c083b2447491 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Tue, 27 Oct 2026 18:04:25 +0100 Subject: [PATCH 04/10] fix: integrate command capture with current native options - fit command settings into the tabbed options layout - preserve captured UTF-8 prefixes and rasterize missing text glyphs - verify tab persistence, layout, and Unicode rendering regressions --- .../MatrixScreenSaver/CmdCaptureScene.swift | 8 ++--- .../MatrixScreenSaverOptions.swift | 8 ++--- .../MatrixScreenSaverView.swift | 23 +++++++++++++- .../CmdCaptureSceneTests.swift | 4 +++ .../SharedSaverTests.swift | 30 +++++++++++++++++++ 5 files changed, 63 insertions(+), 10 deletions(-) diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift index 190544e..9595792 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -172,8 +172,8 @@ final class CmdCaptureTask { /// - Parameters: /// - command: Shell command, interpreted with the host environment and extended tool PATH. /// - timeout: Finite positive deadline in seconds; invalid values prevent execution. - /// - Returns: UTF-8 stdout, or stderr when stdout is blank. Empty on cancellation, - /// timeout, invalid UTF-8, launch failure, or repeated use. Nonzero exit may still yield output. + /// - Returns: UTF-8 stdout, or stderr when stdout is blank. Incomplete or invalid UTF-8 + /// uses replacement characters. Empty on cancellation, timeout, launch failure, or repeated use. func captureOutput(command: String, timeout: TimeInterval = CmdCaptureScene.captureTimeout) -> String { guard !command.isEmpty, !command.utf8.contains(0), timeout.isFinite, timeout > 0 else { return "" } let deadline = ProcessInfo.processInfo.systemUptime + timeout @@ -264,8 +264,8 @@ final class CmdCaptureTask { // Kernel-delayed exit must not block the caller; the exit event still reaps it later. _ = reaped.wait(timeout: .now() + 1) guard completed, !isCancelled else { return "" } - let output = String(data: stdout, encoding: .utf8) ?? "" - let error = String(data: stderr, encoding: .utf8) ?? "" + let output = String(decoding: stdout, as: UTF8.self) + let error = String(decoding: stderr, as: UTF8.self) return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !error.isEmpty ? error : output } diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index a478396..5170c1f 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -1046,11 +1046,11 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, scrollView.hasVerticalScroller = true scrollView.borderType = .bezelBorder scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.widthAnchor.constraint(equalToConstant: Self.textFieldWidth * 1.6).isActive = true scrollView.heightAnchor.constraint(equalToConstant: 90).isActive = true stack.addArrangedSubview(label) stack.addArrangedSubview(scrollView) + scrollView.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true stack.addArrangedSubview(makeDescriptionLabel(description)) return stack } @@ -1208,13 +1208,11 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return MatrixScreenSaverOptions( neoMessageSceneEnabled: neoMessageSceneCheckbox.state == .on, neoMessageSpeedFactor: neoMessageSpeedFactor, - name: nameField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), + name: nameField.stringValue, neoMessageLines: pendingLines, cmdSceneEnabled: cmdSceneCheckbox.state == .on, commands: commandsTextView.string - .components(separatedBy: .newlines) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty }, + .components(separatedBy: .newlines), 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 6081135..de3f901 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 @@ -642,6 +650,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 @@ -938,6 +947,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 @@ -1026,7 +1036,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/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift index a315c92..de19c93 100644 --- a/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -86,6 +86,10 @@ func cmdCaptureTaskTests() { ok(large.utf8.count == 65536 && large.allSatisfy { $0 == "x" }, "CmdCaptureTask: drains simultaneous streams with bounded retained 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) diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index ef7acb9..29e3420 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -73,6 +73,9 @@ func sharedSaverTests() { var options = MatrixScreenSaverOptions.load(from: defaults) options.frameRate = 42 options.neoMessageLines = ["Shared options"] + options.name = "Trinity" + options.cmdSceneEnabled = true + options.commands = ["printf first", "printf second"] var applied = false let controller = MatrixScreenSaverOptionsSheetController { edited in edited.save(to: defaults) @@ -86,6 +89,8 @@ func sharedSaverTests() { 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, + "tabbed options preserve the Neo name and command settings through Apply and persistence") options.frameRate = -10 options.save(to: defaults) check(MatrixScreenSaverOptions.load(from: defaults).frameRate > 0, "shared persistence retains option sanitization") @@ -170,6 +175,29 @@ private func glyphCacheTests() { check(glyph("A") != nil && glyph("A", bold: true) != nil, "restarting after resource cleanup recreates usable glyph caches") view.stopAnimation() + var capturedOptions = MatrixScreenSaverOptions() + capturedOptions.neoMessageSceneEnabled = false + capturedOptions.cmdSceneEnabled = true + capturedOptions.numberSceneEnabled = false + capturedOptions.commands = ["printf '\\303\\251'"] + capturedOptions.scanLinesIntensity = 0 + let container = NSView(frame: view.frame) + container.addSubview(view) + defer { view.removeFromSuperview() } + view.startAnimation() + view.applyWallpaperOptions(capturedOptions) + let renderer: NativeMatrixRenderer? = stored("nativeRenderer", in: view) + let captured = eventually { + view.animateOneFrame() + return renderer?[NativeMatrixRenderer.contentMargin + 1, NativeMatrixRenderer.contentMargin].scalar == "é" + } + check(captured, "command output reaches the active native view before raster comparison") + let fallbackPixels = pixels(of: view) + capturedOptions.characters = "é" + view.applyWallpaperOptions(capturedOptions) + check(captured && fallbackPixels != nil && fallbackPixels == pixels(of: view), + "captured Unicode text matches glyphs explicitly included in the rain character cache") + view.stopAnimation() } /// Checks every native tab at its laid-out size and leaves an edit on an inactive page for Apply. @@ -183,6 +211,8 @@ private func checkOptionsLayout(in window: NSWindow) { check(false, "options organize settings into native tabs") return } + check(tabs.tabViewItems.map(\.label) == ["General", "Intro scenes", "Command capture", "Rain"], + "command capture integrates into the current native tab structure") for item in tabs.tabViewItems { tabs.selectTabViewItem(item) content.layoutSubtreeIfNeeded() From 6d92a5ab734000a4ded3690955ad7a6296855de7 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 29 Oct 2026 14:12:37 +0100 Subject: [PATCH 05/10] fix: stream custom scenes with shared native options - add scrolling live command output, terminal sizing and blinking cursor - add reorderable commands and independent typing speed defaulting to 10 - synchronize first playback and handle black-background failures gracefully - cover scene playback, options persistence and wallpaper recovery --- README.md | 17 +- .../MatrixScreenSaver/CmdCaptureScene.swift | 258 +++++++------- .../MatrixScreenSaverOptions.swift | 318 +++++++++++++----- .../MatrixScreenSaverView.swift | 3 +- .../NativeMatrixRenderer.swift | 140 ++++---- Sources/MatrixWallpaper/WallpaperApp.swift | 4 +- .../CmdCaptureSceneTests.swift | 105 ++++-- .../NativeMatrixRendererTests.swift | 171 +++++++++- .../SharedSaverTests.swift | 225 ++++++++++++- .../WallpaperAppTests.swift | 51 +++ 10 files changed, 979 insertions(+), 313 deletions(-) diff --git a/README.md b/README.md index c161257..ab03f76 100644 --- a/README.md +++ b/README.md @@ -41,18 +41,19 @@ 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 | +| Typing speed | Typing speed multiplier for Neo messages and the trace program. A positive number; message holds keep their natural duration. | 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)* | -| cmd capture scene | Run a command and reveal its captured stdout before the trace program (Number scene). Runs after the Neo message and before the Number scene. Skipped entirely if disabled or if no commands are configured. | Off | -| cmd commands | One shell command per line. A random one runs each time the screen saver starts (all displays in a multi-monitor setup agree on the same pick). Empty disables the scene. | *(empty)* | +| Show custom scene | Execute custom commands and type live output into consecutive rows as it arrives. 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 | +| Custom typing speed | Independent positive speed multiplier in the custom fieldset. 1 = 25 characters/second; use e.g. 40 for 1,000 characters/second. No pauses between output rows; final output holds briefly. | 10 | +| command playback | Radio buttons select all commands in sequence or one random command on each scene entry (all displays agree on the random pick). Add, edit, remove, or drag command rows to change their execution order in **Edit…**. Sequence mode finishes 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) | -Command capture executes your configured commands through `/bin/sh` with the host's permissions. +Custom shell commands execute through `/bin/sh` with the host's permissions. Only enter commands you trust, and avoid sensitive output: it is visible on the screen saver, -including while the screen is locked. Command text and output are not logged. Each capture has -a 12-second deadline and retains at most 64 KiB per output stream; stopping or restarting playback +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 @@ -66,14 +67,14 @@ cancels its command. The sandboxed screen saver may not have the same access as - 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, cmd capture scene/commands, 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` - runs a command, captures its output, and reveals it before the trace program +- `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 index 9595792..94b60de 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -3,134 +3,147 @@ import Darwin /// The drawing state snapshot the view uses to render the current cmd capture frame. struct CmdCaptureRenderState { - /// Captured rows, already clipped to the display dimensions. + /// Visible output rows, including the partially typed bottom row. let lines: [String] - /// Number of rows currently revealed. - let visibleLineCount: Int - /// Launching indicator, absent after capture finishes. + /// Launching indicator, absent as soon as output arrives. let statusLine: String? + /// Uses the same blink cadence as Neo messages, including while waiting for output. + let cursorVisible: Bool } -/// Time-driven reveal and hold sequence for output supplied by a background capture task. +/// Types live command output into a bounded viewport, scrolling rather than clearing between rows. struct CmdCaptureScene { /// Stages of capture presentation. enum Phase: Equatable { /// Waiting for a background capture result. case launching - /// Showing captured rows progressively. - case revealing - /// Keeping all captured rows visible. - case holding + /// Appending rows and holding the completed output. + case presenting /// Finished or skipped because the command was empty. case done } - /// Rows revealed per active second. - static let linesPerSecond: Double = 14.0 - /// Seconds the fully revealed output remains visible. - static let holdDuration: TimeInterval = 2.5 - /// Maximum command and pipe-completion duration, excluding bounded cleanup. + /// Default deadline for bounded standalone captures; live scene commands run until exit or cancellation. static let captureTimeout: TimeInterval = 12.0 /// Indicator shown while the background task runs. static let statusLineText = "Querying..." /// Current presentation stage. private(set) var phase: Phase = .done - /// Number of captured rows revealed so far. - private(set) var visibleLineCount: Int = 0 - /// Captured rows clipped to display dimensions. + /// All captured rows, clipped horizontally but retained for scrolling. private(set) var lines: [String] = [] private var startTime: TimeInterval = 0 - private var captureElapsed: TimeInterval? - private var revealEndElapsed: TimeInterval = 0 - private var holdEndElapsed: TimeInterval = 0 + private var lastAdvanceTime: TimeInterval = 0 + private var typedCharacters: Double = 0 + private var captureFinished = false + private var holdStart: TimeInterval? + private var viewportRows = 1 + private var characterCount = 0 + private var speedFactor: Double = 1 - /// Active duration of the scene; `nil` while still launching. - var scheduledDuration: TimeInterval? { - captureElapsed == nil ? nil : holdEndElapsed - } + /// Projected active duration, available only after the command finishes. + private(set) var scheduledDuration: TimeInterval? /// Resets presentation without launching a command. - /// Call ``CmdCaptureTask/captureOutput(command:timeout:)`` on a background queue, - /// then deliver its result through ``receiveCapture(_:maxLines:maxColumns:now:)``. + /// 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`. - mutating func reset(startTime: TimeInterval, command: String) { + /// - speedFactor: Custom typing multiplier; 1 types 25 characters per second. + mutating func reset(startTime: TimeInterval, command: String, speedFactor: Double = 1) { self.startTime = startTime + self.speedFactor = speedFactor.isFinite && speedFactor > 0 ? speedFactor : 1 + typedCharacters = 0 + lastAdvanceTime = startTime + captureFinished = false + holdStart = nil lines = [] - visibleLineCount = 0 - captureElapsed = nil - revealEndElapsed = 0 - holdEndElapsed = 0 + scheduledDuration = nil phase = command.isEmpty ? .done : .launching } - /// Records a capture result and schedules its reveal and hold. + /// Applies the latest bounded output snapshot without restarting its typing progress. /// /// - Parameters: /// - output: Captured command output. - /// - maxLines: Maximum retained nonempty rows, clamped to at least one. + /// - 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:)``. - mutating func receiveCapture(_ output: String, maxLines: Int, maxColumns: Int, now: TimeInterval) { - guard phase == .launching else { return } - let trimmedLines = output - .split(separator: "\n", maxSplits: max(maxLines, 1), omittingEmptySubsequences: true) - .prefix(max(maxLines, 1)) - .map { String($0.prefix(max(maxColumns, 1))) } - lines = trimmedLines.isEmpty ? ["(no output)"] : trimmedLines - - let elapsed = max(now - startTime, 0) - captureElapsed = elapsed - let revealDuration = Double(lines.count) / Self.linesPerSecond - revealEndElapsed = elapsed + revealDuration - holdEndElapsed = revealEndElapsed + Self.holdDuration - phase = .revealing + /// - 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 != .done, !captureFinished else { return } + if phase == .presenting { advance(now: now) } + guard !output.isEmpty || isComplete else { return } + viewportRows = max(maxLines, 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)))) + } + characterCount = lines.reduce(0) { $0 + $1.unicodeScalars.count } + max(lines.count - 1, 0) + typedCharacters = min(typedCharacters, Double(characterCount)) + lastAdvanceTime = now + captureFinished = isComplete + if isComplete { + scheduledDuration = max(now - startTime, 0) + (Double(characterCount) - typedCharacters) * 0.04 / speedFactor + 2.5 + if typedCharacters >= Double(characterCount) { holdStart = now } + } + phase = .presenting } - /// Excludes a suspended interval without rebuilding the reveal schedule. + /// 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 } } - /// Updates the reveal stage using the scene's reference-relative clock. + /// Advances the current message using the scene's reference-relative clock. /// /// - Parameter now: Current reference-relative time in seconds. mutating func advance(now: TimeInterval) { guard phase != .done else { return } - guard let captureElapsed else { return } - let sceneTime = now - startTime - - if sceneTime < captureElapsed { - visibleLineCount = 0 - phase = .revealing - } else if sceneTime < revealEndElapsed { - let revealElapsed = sceneTime - captureElapsed - visibleLineCount = min(lines.count, Int(revealElapsed * Self.linesPerSecond) + 1) - phase = .revealing - } else if sceneTime < holdEndElapsed { - visibleLineCount = lines.count - phase = .holding - } else { - visibleLineCount = lines.count - phase = .done + if phase == .launching { + lastAdvanceTime = now + return } + let needed = max(Double(characterCount) - typedCharacters, 0) + let progress = max(0, now - lastAdvanceTime) * 25 * speedFactor + typedCharacters = min(Double(characterCount), typedCharacters + progress) + if captureFinished, typedCharacters >= Double(characterCount) { + if holdStart == nil { holdStart = lastAdvanceTime + needed * 0.04 / speedFactor } + if now >= holdStart! + 2.5 { phase = .done } + } + lastAdvanceTime = now } /// Snapshot for the native renderer at the current presentation stage. var renderState: CmdCaptureRenderState { + let cursorVisible = Int(max(0, lastAdvanceTime - startTime) / NeoMessageScene.cursorBlinkInterval) % 2 == 0 switch phase { case .done: - return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: nil) + return CmdCaptureRenderState(lines: [], statusLine: nil, cursorVisible: false) case .launching: - return CmdCaptureRenderState(lines: [], visibleLineCount: 0, statusLine: Self.statusLineText) - case .revealing, .holding: - return CmdCaptureRenderState(lines: lines, visibleLineCount: visibleLineCount, statusLine: nil) + return CmdCaptureRenderState(lines: [], statusLine: Self.statusLineText, cursorVisible: cursorVisible) + case .presenting: + var remaining = floor(typedCharacters) + var currentRow = 0 + while currentRow < lines.count - 1 { + let count = lines[currentRow].unicodeScalars.count + if remaining < Double(count + 1) { break } + remaining -= Double(count + 1) + currentRow += 1 + } + var visible = Array(lines[max(0, currentRow - viewportRows + 1).. String { - guard !command.isEmpty, !command.utf8.contains(0), timeout.isFinite, timeout > 0 else { return "" } - let deadline = ProcessInfo.processInfo.systemUptime + timeout - guard var outputPipe = Self.makePipe() else { return "" } - defer { outputPipe.forEach { close($0) } } - guard var errorPipe = Self.makePipe() else { return "" } - defer { errorPipe.forEach { close($0) } } + /// - 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 "" } @@ -190,11 +230,13 @@ final class CmdCaptureTask { 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_addopen(&actions, STDIN_FILENO, "/dev/null", O_RDONLY, 0) == 0, - posix_spawn_file_actions_adddup2(&actions, outputPipe[1], STDOUT_FILENO) == 0, - posix_spawn_file_actions_adddup2(&actions, errorPipe[1], STDERR_FILENO) == 0 else { return "" } + 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]) @@ -227,29 +269,30 @@ final class CmdCaptureTask { exitSource.setEventHandler {} reaped.signal() } - close(outputPipe[1]) - close(errorPipe[1]) - outputPipe[1] = -1 - errorPipe[1] = -1 + close(slave) + slave = -1 var stdout = Data() - var stderr = Data() var outputOpen = true - var errorOpen = true var completed = false while !isCancelled && ProcessInfo.processInfo.systemUptime < deadline { - Self.drain(outputPipe[0], into: &stdout, open: &outputOpen) - Self.drain(errorPipe[0], into: &stderr, open: &errorOpen) + 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 && !errorOpen { + if exited && !outputOpen { completed = true break } var descriptors = [ - pollfd(fd: outputOpen ? outputPipe[0] : -1, events: Int16(POLLIN), revents: 0), - pollfd(fd: errorOpen ? errorPipe[0] : -1, events: Int16(POLLIN), revents: 0), + pollfd(fd: outputOpen ? master : -1, events: Int16(POLLIN), revents: 0), ] _ = poll(&descriptors, nfds_t(descriptors.count), 20) } @@ -263,29 +306,8 @@ final class CmdCaptureTask { exitSource.resume() // Kernel-delayed exit must not block the caller; the exit event still reaps it later. _ = reaped.wait(timeout: .now() + 1) - guard completed, !isCancelled else { return "" } - let output = String(decoding: stdout, as: UTF8.self) - let error = String(decoding: stderr, as: UTF8.self) - return output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !error.isEmpty ? error : output - } - - /// Creates a pipe above standard descriptors, with a nonblocking reader and close-on-exec endpoints. - /// - /// - Returns: Reader and writer descriptors owned by the caller, or `nil` on setup failure. - private static func makePipe() -> [Int32]? { - var raw: [Int32] = [0, 0] - guard pipe(&raw) == 0 else { return nil } - defer { raw.forEach { close($0) } } - let reader = fcntl(raw[0], F_DUPFD_CLOEXEC, 3) - guard reader >= 0 else { return nil } - let writer = fcntl(raw[1], F_DUPFD_CLOEXEC, 3) - guard writer >= 0 else { close(reader); return nil } - guard fcntl(reader, F_SETFL, O_NONBLOCK) == 0 else { - close(reader) - close(writer) - return nil - } - return [reader, writer] + guard !isCancelled else { return "" } + return String(decoding: stdout, as: UTF8.self) } /// Drains a bounded batch so a continuously writing child cannot starve cancellation checks. diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index 5170c1f..7fc47e2 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -8,7 +8,10 @@ struct MatrixScreenSaverOptions: Equatable { static let name = "Name" static let neoMessageLines = "NeoMessageLines" static let cmdSceneEnabled = "CmdSceneEnabled" + static let cmdTypingSpeedFactor = "CmdTypingSpeedFactor" static let commands = "Commands" + /// Preserves random playback for installations without a stored mode. + static let commandsRunInSequence = "CommandsRunInSequence" static let numberSceneEnabled = "NumberSceneEnabled" static let twinkleEnabled = "TwinkleEnabled" static let diffuseEnabled = "DiffuseEnabled" @@ -29,7 +32,11 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultName = "" static let defaultNeoMessageLines: [String] = [] static let defaultCmdSceneEnabled = false + /// Faster custom output by default without changing Neo or trace typing. + static let defaultCmdTypingSpeedFactor = 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 @@ -60,12 +67,16 @@ struct MatrixScreenSaverOptions: Equatable { 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 neoMessageSpeedFactorDescription = "Typing speed multiplier for Neo and trace program. Default: 1.0." + /// Custom output has no pauses between rows; larger multipliers suit long captures. + static let cmdTypingSpeedFactorDescription = "Custom output speed multiplier. 1 = 25 characters/second. 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 = "Run a trusted shell command before the trace program. Off by default. Commands run with the host's permissions; output is visible even while the screen is locked." - static let commandsDescription = "One shell command per line (up to 20, 512 characters each). Overlong commands are ignored. A random one runs each time the screen saver starts. Empty disables the scene." - static let numberSceneDescription = "Show the startup number scene. On by default." + 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; output is 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." @@ -87,7 +98,11 @@ struct MatrixScreenSaverOptions: Equatable { var name = defaultName var neoMessageLines = defaultNeoMessageLines var cmdSceneEnabled = defaultCmdSceneEnabled + /// Custom output speed, independent of Neo and trace typing. + var cmdTypingSpeedFactor = defaultCmdTypingSpeedFactor var commands = defaultCommands + /// Whether each scene entry runs the whole command list in order. + var commandsRunInSequence = defaultCommandsRunInSequence var numberSceneEnabled = defaultNumberSceneEnabled var twinkleEnabled = defaultTwinkleEnabled var diffuseEnabled = defaultDiffuseEnabled @@ -112,6 +127,8 @@ struct MatrixScreenSaverOptions: Equatable { neoMessageSpeedFactor != previous.neoMessageSpeedFactor || neoMessageLines != previous.neoMessageLines || (neoMessageLines.isEmpty && name != previous.name) || cmdSceneEnabled != previous.cmdSceneEnabled || commands != previous.commands || + cmdTypingSpeedFactor != previous.cmdTypingSpeedFactor || + commandsRunInSequence != previous.commandsRunInSequence || numberSceneEnabled != previous.numberSceneEnabled || rainRunForever != previous.rainRunForever || rainDurationSeconds != previous.rainDurationSeconds } @@ -122,7 +139,9 @@ struct MatrixScreenSaverOptions: Equatable { neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: neoMessageSpeedFactor, cmdSceneEnabled: cmdSceneEnabled, + cmdTypingSpeedFactor: cmdTypingSpeedFactor, commands: commands, + commandsRunInSequence: commandsRunInSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -159,7 +178,9 @@ struct MatrixScreenSaverOptions: Equatable { name: name.trimmingCharacters(in: .whitespacesAndNewlines), neoMessageLines: sanitizedLines, cmdSceneEnabled: cmdSceneEnabled, + cmdTypingSpeedFactor: cmdTypingSpeedFactor.isFinite && cmdTypingSpeedFactor > 0 ? cmdTypingSpeedFactor : Self.defaultCmdTypingSpeedFactor, commands: sanitizedCommands, + commandsRunInSequence: commandsRunInSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -197,7 +218,9 @@ struct MatrixScreenSaverOptions: Equatable { Keys.name: defaultName, Keys.neoMessageLines: defaultNeoMessageLines, Keys.cmdSceneEnabled: defaultCmdSceneEnabled, + Keys.cmdTypingSpeedFactor: defaultCmdTypingSpeedFactor, Keys.commands: defaultCommands, + Keys.commandsRunInSequence: defaultCommandsRunInSequence, Keys.numberSceneEnabled: defaultNumberSceneEnabled, Keys.twinkleEnabled: defaultTwinkleEnabled, Keys.diffuseEnabled: defaultDiffuseEnabled, @@ -222,7 +245,9 @@ struct MatrixScreenSaverOptions: Equatable { name: defaults.string(forKey: Keys.name) ?? defaultName, neoMessageLines: (defaults.array(forKey: Keys.neoMessageLines) as? [String]) ?? defaultNeoMessageLines, cmdSceneEnabled: defaults.bool(forKey: Keys.cmdSceneEnabled), + cmdTypingSpeedFactor: defaults.double(forKey: Keys.cmdTypingSpeedFactor), commands: (defaults.array(forKey: Keys.commands) as? [String]) ?? defaultCommands, + commandsRunInSequence: defaults.bool(forKey: Keys.commandsRunInSequence), numberSceneEnabled: defaults.bool(forKey: Keys.numberSceneEnabled), twinkleEnabled: defaults.bool(forKey: Keys.twinkleEnabled), diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), @@ -247,7 +272,9 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.name, forKey: Keys.name) defaults.set(options.neoMessageLines, forKey: Keys.neoMessageLines) defaults.set(options.cmdSceneEnabled, forKey: Keys.cmdSceneEnabled) + defaults.set(options.cmdTypingSpeedFactor, forKey: Keys.cmdTypingSpeedFactor) defaults.set(options.commands, forKey: Keys.commands) + defaults.set(options.commandsRunInSequence, forKey: Keys.commandsRunInSequence) defaults.set(options.numberSceneEnabled, forKey: Keys.numberSceneEnabled) defaults.set(options.twinkleEnabled, forKey: Keys.twinkleEnabled) defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) @@ -270,6 +297,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, NSTableViewDataSource, NSTableViewDelegate { private enum ValidationError: LocalizedError { case neoMessageSpeedFactor + case cmdTypingSpeedFactor case rainDensity case frameRate case errorRate @@ -278,7 +306,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 .cmdTypingSpeedFactor: + return "Custom typing speed must be a finite positive number." case .rainDensity: return "Rain density must be a positive number." case .frameRate: @@ -299,6 +329,7 @@ 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 cmdTypingSpeedFactorField = NSTextField(string: "") private let nameField = NSTextField(string: "") private let linesTableView = NSTableView() private let linesScrollView = NSScrollView() @@ -312,10 +343,31 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private static let removeColumnID = NSUserInterfaceItemIdentifier("remove") private var pendingLines: [String] = MatrixScreenSaverOptions.defaultNeoMessageLines private var linesEditorWindow: NSWindow? - private let cmdSceneCheckbox = NSButton(checkboxWithTitle: "Show command capture scene", target: nil, action: nil) - private let commandsTextView = NSTextView() - private let commandsScrollView = NSScrollView() - 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) + /// 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 = { @@ -373,10 +425,13 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, func prepare(using options: MatrixScreenSaverOptions) { neoMessageSceneCheckbox.state = options.neoMessageSceneEnabled ? .on : .off neoMessageSpeedFactorField.stringValue = Self.format(options.neoMessageSpeedFactor) + cmdTypingSpeedFactorField.stringValue = Self.format(options.cmdTypingSpeedFactor) nameField.stringValue = options.name pendingLines = options.neoMessageLines cmdSceneCheckbox.state = options.cmdSceneEnabled ? .on : .off - commandsTextView.string = options.commands.joined(separator: "\n") + pendingCommands = options.commands + commandsSequenceRadio.state = options.commandsRunInSequence ? .on : .off + commandsRandomRadio.state = options.commandsRunInSequence ? .off : .on numberSceneCheckbox.state = options.numberSceneEnabled ? .on : .off twinkleCheckbox.state = options.twinkleEnabled ? .on : .off diffuseCheckbox.state = options.diffuseEnabled ? .on : .off @@ -396,6 +451,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters + updateSceneControls() sizeWindowToFitContent() } @@ -449,6 +505,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, characterSizeSlider.target = self characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) neoMessageSpeedFactorField.formatter = Self.numberFormatter + cmdTypingSpeedFactorField.formatter = Self.numberFormatter + cmdTypingSpeedFactorField.delegate = self rainDensityField.formatter = Self.numberFormatter rainDurationField.formatter = Self.integerFormatter frameRateField.formatter = Self.numberFormatter @@ -460,6 +518,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 @@ -473,7 +535,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(), @@ -481,16 +544,15 @@ 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), - makeTextSection(title: "Name", field: nameField, description: MatrixScreenSaverOptions.nameDescription, fieldWidth: Self.textFieldWidth / 2), - 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: "Command capture", views: [ - makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), - makeMultilineTextSection(title: "Commands", textView: commandsTextView, scrollView: commandsScrollView, description: MatrixScreenSaverOptions.commandsDescription), - ])) tabs.addTabViewItem(makeOptionsTab(title: "Rain", views: [ makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), @@ -505,7 +567,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), ]) @@ -577,6 +639,86 @@ 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() { + cmdTypingSpeedFactorField.isEnabled = cmdSceneCheckbox.state == .on + nameField.isEnabled = neoMessageSceneCheckbox.state == .on + linesEditButton.isEnabled = neoMessageSceneCheckbox.state == .on + for control in [commandsEditButton, commandsSequenceRadio, commandsRandomRadio] { + 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] { + radio.target = self + radio.action = #selector(commandModeChanged(_:)) + } + let modes = NSStackView(views: [commandsSequenceRadio, commandsRandomRadio]) + modes.spacing = 12 + let stack = NSStackView(views: [ + makeCheckboxSection(checkbox: cmdSceneCheckbox, description: MatrixScreenSaverOptions.cmdSceneDescription), + heading, modes, + makeNumericSection(title: "Typing speed", field: cmdTypingSpeedFactorField, + description: MatrixScreenSaverOptions.cmdTypingSpeedFactorDescription), + ]) + cmdTypingSpeedFactorField.setAccessibilityLabel("Custom typing speed") + 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 + } + + /// 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() @@ -595,18 +737,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), @@ -662,7 +805,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" @@ -692,13 +836,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 @@ -717,9 +870,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) @@ -727,16 +893,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 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) } @@ -752,7 +925,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() @@ -766,7 +939,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() @@ -783,9 +956,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 { @@ -821,15 +994,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 @@ -871,9 +1046,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 @@ -884,13 +1059,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: @@ -902,6 +1080,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() @@ -1023,38 +1218,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } - /// Builds a labeled multi-line text input (one entry per line) with its description. - private func makeMultilineTextSection(title: String, textView: NSTextView, scrollView: NSScrollView, description: String) -> NSView { - let stack = NSStackView() - stack.orientation = .vertical - stack.alignment = .leading - stack.spacing = 4 - stack.setHuggingPriority(.required, for: .vertical) - - let label = NSTextField(labelWithString: title) - label.font = .systemFont(ofSize: 13, weight: .semibold) - - textView.font = .monospacedSystemFont(ofSize: 12, weight: .regular) - textView.isRichText = false - textView.isAutomaticQuoteSubstitutionEnabled = false - textView.isAutomaticDashSubstitutionEnabled = false - textView.isAutomaticSpellingCorrectionEnabled = false - textView.setAccessibilityLabel(title) - textView.textContainerInset = NSSize(width: 6, height: 6) - - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.borderType = .bezelBorder - scrollView.translatesAutoresizingMaskIntoConstraints = false - scrollView.heightAnchor.constraint(equalToConstant: 90).isActive = true - - stack.addArrangedSubview(label) - stack.addArrangedSubview(scrollView) - scrollView.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true - stack.addArrangedSubview(makeDescriptionLabel(description)) - return stack - } - /// Builds the rain duration section with "Run forever" checkbox and optional seconds field. private func makeRainDurationSection() -> NSView { let stack = NSStackView() @@ -1184,6 +1347,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 cmdTypingSpeedFactor = try parseDouble(from: cmdTypingSpeedFactorField, error: .cmdTypingSpeedFactor) + guard cmdTypingSpeedFactor.isFinite, cmdTypingSpeedFactor > 0 else { + throw ValidationError.cmdTypingSpeedFactor + } let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) let rainDurationSeconds = try parseInteger(from: rainDurationField, error: .rainDurationSeconds) let frameRate = try parseDouble(from: frameRateField, error: .frameRate) @@ -1211,8 +1378,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, name: nameField.stringValue, neoMessageLines: pendingLines, cmdSceneEnabled: cmdSceneCheckbox.state == .on, - commands: commandsTextView.string - .components(separatedBy: .newlines), + cmdTypingSpeedFactor: cmdTypingSpeedFactor, + commands: pendingCommands, + commandsRunInSequence: commandsSequenceRadio.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 de3f901..e320798 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -348,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 } diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index de5a1bb..5e87f65 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -5,7 +5,11 @@ final class NativeMatrixRenderer { var neoMessageSceneEnabled = true var neoMessageSpeedFactor = 1.0 var cmdSceneEnabled = false + /// Independent custom output typing multiplier. + var cmdTypingSpeedFactor = 10.0 var commands: [String] = [] + /// Runs every command in list order instead of choosing one per scene entry. + var commandsRunInSequence = false var numberSceneEnabled = true var twinkleEnabled = true var diffuseEnabled = true @@ -28,9 +32,11 @@ final class NativeMatrixRenderer { neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), cmdSceneEnabled: cmdSceneEnabled, + cmdTypingSpeedFactor: cmdTypingSpeedFactor.isFinite && cmdTypingSpeedFactor > 0 ? cmdTypingSpeedFactor : 10, commands: commands .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty }, + commandsRunInSequence: commandsRunInSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -284,10 +290,14 @@ 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] = [] @@ -370,13 +380,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) @@ -413,6 +424,7 @@ final class NativeMatrixRenderer { cmdCaptureTask?.cancel() cmdCaptureTask = nil pendingCmdCaptureOutput = nil + displayedCmdCaptureOutput = nil } /// Excludes paused wall-clock time while retaining cells, random state, and scene progress. @@ -513,7 +525,9 @@ final class NativeMatrixRenderer { rows > 0 && (previousConfiguration.neoMessageSceneEnabled != sanitizedConfiguration.neoMessageSceneEnabled || previousConfiguration.cmdSceneEnabled != sanitizedConfiguration.cmdSceneEnabled || + previousConfiguration.cmdTypingSpeedFactor != sanitizedConfiguration.cmdTypingSpeedFactor || previousConfiguration.commands != sanitizedConfiguration.commands || + previousConfiguration.commandsRunInSequence != sanitizedConfiguration.commandsRunInSequence || previousConfiguration.numberSceneEnabled != sanitizedConfiguration.numberSceneEnabled || previousConfiguration.neoMessageLines != sanitizedConfiguration.neoMessageLines) @@ -648,59 +662,9 @@ final class NativeMatrixRenderer { return blackoutRounds } - /// Skips the "Call trans opt / Trace program" typed lines and drops straight into the - /// number-rain trace, using whatever is already on screen (the held cmd output) as the - /// starting frame so it reads as a dissolve rather than a hard cut. - private func beginNumberRainFromCmd() { - activeScene = .numberIntro - numberSceneRNG = Xorshift64(seed: sceneSeed &+ 2) - numberIntroAnchor = Date.timeIntervalSinceReferenceDate - numberIntroSchedule = NumberIntroSchedule( - typingText: "", - cursorBlinkEnd: 0, - charTimings: [], - typingEnd: 0, - pause1Duration: 0, - pause1End: 0, - pause2Duration: 0, - rainStart: 0, - blackoutRounds: computeBlackoutRounds(rng: &numberSceneRNG) - ) - numberIntroPhase = .rain - numberIntroRainFrame = 0 - numberIntroBlackoutCount = 0 - numberIntroBlackedColumns = [] - dissolveCmdOutputIntoNumberRain() - markAllRowsDirty() - } - - /// Replaces the currently-visible cmd output glyphs with random digits in place, and - /// fills the remaining blank content rows with random digits — a single-frame "dissolve" - /// from the captured text into the number rain that follows. Cells that held visible - /// cmd text stay at full brightness/bold so the previous text shape briefly reads - /// through the new digits. - private func dissolveCmdOutputIntoNumberRain() { - let m = Self.numberIntroMargin - guard rows > m * 2, columns > m * 2 else { return } - let levelCount = max(levelColors.count, 1) - let rowStart = m; let rowEnd = rows - m - let colStart = m; let colEnd = columns - m - for row in rowStart..= 0, row < rows, columns > m * 2 else { return } let baseGreenLevel = max(levelColors.count / 2, 1) @@ -894,6 +856,12 @@ final class NativeMatrixRenderer { backgroundLevel: 0, bold: false) col += 1 } + if showCursor { + let cursorColumn = min(col, colEnd - 1) + renderCells[offset + cursorColumn] = RenderCell(scalar: UnicodeScalar(0x2588)!, foregroundLevel: baseGreenLevel, + backgroundLevel: 0, bold: false) + col = max(col, cursorColumn + 1) + } visibleCountByRow[row] = col - colStart } @@ -1048,13 +1016,11 @@ final class NativeMatrixRenderer { } } - /// Moves from the cmd capture scene to the next scene in the sequence. Skips the typed - /// "Call trans opt / Trace program" lines and dissolves the cmd output straight into the - /// number-rain trace instead, since by this point the operator has already read the - /// captured output. + /// Starts full trace typing after custom messages clear, excluding capture runtime from its clock. private func transitionFromCmdCapture() { if configuration.numberSceneEnabled { - beginNumberRainFromCmd() + activeScene = .numberIntro + initNumberIntroScene(playbackAnchor: Date.timeIntervalSinceReferenceDate) } else { beginRainScene() stepRainFrame() @@ -1077,15 +1043,20 @@ final class NativeMatrixRenderer { // 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 command = configuration.commands.randomElement(using: &commandPickerRNG) ?? "" + let command = configuration.commandsRunInSequence + ? configuration.commands[commandIndex] + : configuration.commands.randomElement(using: &commandPickerRNG) ?? "" - cmdScene.reset(startTime: Date.timeIntervalSinceReferenceDate, command: command) + cmdScene.reset(startTime: Date.timeIntervalSinceReferenceDate, command: command, + speedFactor: configuration.cmdTypingSpeedFactor) guard cmdScene.phase != .done else { return } let task = CmdCaptureTask() cmdCaptureTask = task + 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) + 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 @@ -1099,23 +1070,33 @@ final class NativeMatrixRenderer { if let output = pendingCmdCaptureOutput { pendingCmdCaptureOutput = nil let m = Self.numberIntroMargin - // Leave the bottom margin outside the later Number-scene dissolve. - cmdScene.receiveCapture(output, maxLines: max(rows - m * 2 - 1, 1), + 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) let state = cmdScene.renderState - let m = Self.numberIntroMargin + for i in renderCells.indices { renderCells[i] = Self.blankRenderCell } + for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } if let statusLine = state.statusLine { - setRow0(text: statusLine, typedCount: statusLine.unicodeScalars.count, showCursor: true) + setRow0(text: statusLine, typedCount: statusLine.unicodeScalars.count, showCursor: state.cursorVisible) } else { - setRow0(text: "", typedCount: 0, showCursor: false) - for (index, line) in state.lines.prefix(state.visibleLineCount).enumerated() { - setRow(m + 1 + index, text: line) + for (index, line) in state.lines.enumerated() { + setRow(Self.numberIntroMargin + index, text: line, + showCursor: state.cursorVisible && index == state.lines.count - 1) } } markAllRowsDirty() if cmdScene.phase == .done { + if configuration.commandsRunInSequence && commandIndex + 1 < configuration.commands.count { + commandIndex += 1 + beginCmdCaptureScene() + return + } if configuration.skipSyncDelay { transitionFromCmdCapture() } else { @@ -1127,6 +1108,7 @@ final class NativeMatrixRenderer { /// Chooses and initializes the first scene for the current session. private func beginSceneSequence() { cancelCmdCapture() + commandIndex = 0 neoSceneDoneRequested = false cmdSceneDoneRequested = false numberSceneDoneRequested = false diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index 0e1cfea..df09517 100644 --- a/Sources/MatrixWallpaper/WallpaperApp.swift +++ b/Sources/MatrixWallpaper/WallpaperApp.swift @@ -304,10 +304,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 index de19c93..d89dee3 100644 --- a/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -23,30 +23,72 @@ func cmdCaptureSceneTests() { ok(scene.phase == .launching, "CmdCaptureScene: command resets to launching phase") ok(scene.scheduledDuration == nil, "CmdCaptureScene: scheduledDuration is nil while launching") - scene.receiveCapture("line 1\nline 2\nline 3\n", maxLines: 2, maxColumns: 50, now: 101.0) - ok(scene.phase == .revealing, "CmdCaptureScene: receiveCapture transitions to revealing phase") - ok(scene.lines.count == 2, "CmdCaptureScene: receiveCapture caps lines to maxLines") - ok(scene.lines == ["line 1", "line 2"], "CmdCaptureScene: receiveCapture parses output correctly") - ok(scene.scheduledDuration != nil, "CmdCaptureScene: scheduledDuration is populated after capture") - - scene.advance(now: 101.0) - ok(scene.visibleLineCount == 1, "CmdCaptureScene: first line reveals immediately") + scene.receiveCapture("abcdef\n\nsecond\nthird\n", maxLines: 2, maxColumns: 3, now: 101) + ok(scene.lines == ["abc", "", "sec", "thi"], + "CmdCaptureScene: clips width, preserves blank rows and retains output beyond screen height") + ok(scene.renderState.lines == [""], "CmdCaptureScene: starts with an empty typing row") + scene.advance(now: 101.13) + ok(scene.renderState.lines == ["abc"], "CmdCaptureScene: types the first clipped row") + scene.advance(now: 101.17) + ok(scene.renderState.lines == ["abc", ""], "CmdCaptureScene: appends instead of clearing between rows") + scene.advance(now: 101.25) + ok(scene.renderState.lines == ["", "s"], "CmdCaptureScene: scrolls only the top row off when viewport fills") scene.shiftTimeline(by: 20) - scene.advance(now: 121.08) - ok(scene.visibleLineCount == 2 && scene.phase == .revealing, "CmdCaptureScene: suspension preserves progressive reveal") - scene.advance(now: 121.2) - ok(scene.phase == .holding, "CmdCaptureScene: holds after revealing all lines") - scene.shiftTimeline(by: -20) + scene.advance(now: 121.25) + ok(scene.renderState.lines == ["", "s"], "CmdCaptureScene: suspension preserves partially typed scrolling output") + scene.advance(now: 122) + ok(scene.renderState.lines == ["sec", "thi"], "CmdCaptureScene: final screen retains the last rows during hold") + scene.advance(now: 130) + ok(scene.phase == .done && scene.renderState.lines.isEmpty, "CmdCaptureScene: finishes after final hold") + let duration = scene.scheduledDuration! + scene.reset(startTime: 100, command: "next", speedFactor: 100) + ok(scene.renderState.lines.isEmpty && scene.lines.isEmpty, "CmdCaptureScene: new command clears prior output") + scene.receiveCapture("abcdef\n\nsecond\nthird\n", maxLines: 2, maxColumns: 3, now: 101) + ok(scene.scheduledDuration! < duration, "CmdCaptureScene: independent custom speed accelerates output") var emptyOutputScene = CmdCaptureScene() emptyOutputScene.reset(startTime: 100.0, command: "true") emptyOutputScene.receiveCapture("", maxLines: 10, maxColumns: 50, now: 101.0) ok(emptyOutputScene.lines == ["(no output)"], "CmdCaptureScene: empty output falls back to placeholder") - // Advance past reveal and hold - let totalDuration = scene.scheduledDuration ?? 0 - scene.advance(now: 100.0 + totalDuration + 1.0) - ok(scene.phase == .done, "CmdCaptureScene: advance past hold duration transitions to done") + var live = CmdCaptureScene() + live.reset(startTime: 100, command: "stream", speedFactor: 1) + live.receiveCapture("one\n", maxLines: 2, maxColumns: 20, now: 101, isComplete: false) + live.advance(now: 120) + ok(live.phase == .presenting && live.renderState.lines == ["one"] && live.scheduledDuration == nil, + "CmdCaptureScene: an idle live command keeps its visible rows without completing") + live.receiveCapture("one\ntwo\n", maxLines: 2, maxColumns: 20, now: 121, isComplete: false) + ok(live.renderState.lines == ["one"], "CmdCaptureScene: idle time does not pretype newly arriving output") + live.advance(now: 121.2) + ok(live.renderState.lines == ["one", "two"], "CmdCaptureScene: later output appends without retyping earlier rows") + live.receiveCapture("one\ntwo\n", maxLines: 2, maxColumns: 20, now: 130) + live.advance(now: 131) + ok(live.phase == .presenting, "CmdCaptureScene: final hold starts at command exit, not first output") + live.advance(now: 133) + ok(live.phase == .done, "CmdCaptureScene: completes only after exit and final hold") + live.reset(startTime: 100, command: "partial stream") + live.receiveCapture("a", maxLines: 2, maxColumns: 20, now: 101, isComplete: false) + live.advance(now: 110) + live.receiveCapture("ab", maxLines: 2, maxColumns: 20, now: 111, isComplete: false) + ok(live.renderState.lines == ["a"], "CmdCaptureScene: an unterminated row does not bank typing progress for its next character") + live.advance(now: 111.05) + ok(live.renderState.lines == ["ab"], "CmdCaptureScene: a partial-line update types only newly arrived characters") + live.reset(startTime: 100, command: "cursor") + ok(live.renderState.cursorVisible, "CmdCaptureScene: waiting for output starts with a visible cursor") + live.advance(now: 100.55) + ok(!live.renderState.cursorVisible, "CmdCaptureScene: waiting cursor blinks at the Neo cadence") + live.receiveCapture("a\nb\nc", maxLines: 2, maxColumns: 20, now: 100.6, isComplete: false) + live.advance(now: 101.1) + ok(live.renderState.lines == ["b", "c"] && live.renderState.cursorVisible, + "CmdCaptureScene: scrolling output retains its blinking cursor") + live.shiftTimeline(by: 20) + live.advance(now: 121.1) + ok(live.renderState.cursorVisible, "CmdCaptureScene: suspension preserves the cursor blink phase") + live.advance(now: 121.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: 122) + live.advance(now: 125) + ok(!live.renderState.cursorVisible, "CmdCaptureScene: completion removes the cursor") cmdCaptureTaskTests() } @@ -67,9 +109,32 @@ func cmdCaptureTaskTests() { 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\n", "CmdCaptureTask: stdout takes priority") - ok(CmdCaptureTask().captureOutput(command: "printf ' '; printf 'diagnostic' >&2; exit 1") == "diagnostic", "CmdCaptureTask: stderr fallback survives nonzero exit") + 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") @@ -83,7 +148,7 @@ func cmdCaptureTaskTests() { 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" }, "CmdCaptureTask: drains simultaneous streams with bounded retained output") + 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( diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift index 14a5a88..c3f060e 100644 --- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift +++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift @@ -2,6 +2,10 @@ import Foundation func nativeMatrixRendererTests() { commandCaptureLifecycleTests() + customTraceHandoffTests() + customShellCommandSequenceTests() + customScrollingViewportTests() + customLiveOutputTests() ok(NativeMatrixRenderer().seedOffset == 0, "NativeMatrixRenderer: seedOffset defaults to 0") var named = NativeMatrixRenderer.Configuration() @@ -167,7 +171,7 @@ private func commandCaptureLifecycleTests() { configuration.commands = ["printf CAPTURE"] renderer.updateConfiguration(configuration) renderer.start() - let row = NativeMatrixRenderer.contentMargin + 1 + let row = NativeMatrixRenderer.contentMargin func displaysCapture() -> Bool { let scalars = (0..<40).map { renderer[row, $0].scalar } return String(String.UnicodeScalarView(scalars)).contains("CAPTURE") @@ -193,7 +197,168 @@ private func commandCaptureLifecycleTests() { renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - capturePauseStart) RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) renderer.advance() - ok(displaysCapture() && !renderer.consumeCmdSceneDone(), - "Command capture: completion during suspension gets its full reveal interval on resume") + ok(!displaysCapture() && !renderer.consumeCmdSceneDone(), + "Command capture: completion during suspension starts with a blank viewport before typing 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.numberSceneEnabled = true + renderer.updateConfiguration(configuration) + renderer.resize(to: TerminalSize(columns: 100, rows: 20)) + renderer.start() + 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.cmdTypingSpeedFactor = 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() + 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 && 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.cmdTypingSpeedFactor = 100 + 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.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 every prior output row") +} + +/// 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 + 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.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.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") } diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 29e3420..40d8740 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,11 +72,23 @@ func sharedSaverTests() { defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() } MatrixScreenSaverOptions.registerDefaults(in: defaults) var options = MatrixScreenSaverOptions.load(from: defaults) + check(options.cmdTypingSpeedFactor == 10 && MatrixScreenSaverOptions().cmdTypingSpeedFactor == 10 + && NativeMatrixRenderer.Configuration().cmdTypingSpeedFactor == 10, + "custom typing speed defaults to ten in persistence, options and the renderer") + options.cmdTypingSpeedFactor = 1 + options.save(to: defaults) + check(MatrixScreenSaverOptions.load(from: defaults).cmdTypingSpeedFactor == 1, + "the new custom speed default preserves an explicitly saved value") 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.neoMessageSpeedFactor = 2 + options.cmdTypingSpeedFactor = 40 var applied = false let controller = MatrixScreenSaverOptionsSheetController { edited in edited.save(to: defaults) @@ -83,14 +96,24 @@ func sharedSaverTests() { } controller.prepare(using: options) 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, + check(loaded.name == "Trinity" && loaded.cmdSceneEnabled && loaded.commands == options.commands + && !loaded.neoMessageSceneEnabled && loaded.commandsRunInSequence && loaded.neoMessageSpeedFactor == 2 + && loaded.cmdTypingSpeedFactor == 40 && loaded.rendererConfiguration().cmdTypingSpeedFactor == 40, "tabbed options preserve the Neo name and command settings through Apply and persistence") + 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") @@ -99,7 +122,16 @@ func sharedSaverTests() { 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") + "command sanitization rejects invalid text instead of executing a clipped command") + for value in [Double.nan, .infinity, 0, -1] { + commandOptions.cmdTypingSpeedFactor = value + check(commandOptions.sanitized().cmdTypingSpeedFactor == 10, + "custom speed rejects nonfinite and nonpositive persisted values") + } + commandOptions = original + commandOptions.cmdTypingSpeedFactor = 100 + check(commandOptions.requiresSceneRestart(comparedTo: original), + "custom typing speed changes restart all participating displays") var live = original live.twinkleEnabled.toggle() live.diffuseEnabled.toggle() @@ -136,6 +168,47 @@ func sharedSaverTests() { "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) @@ -242,6 +313,144 @@ 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() == "Custom typing speed" && $0.stringValue == "40" }, + "custom fieldset owns an independent typing speed 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..1304e42 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,6 +142,16 @@ 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") optionsWindow?.performClose(nil) @@ -266,6 +277,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)" From 5ab076e1e4551ffe6f372e0d8abd37075b0e183b Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 29 Oct 2026 14:39:49 +0100 Subject: [PATCH 06/10] fix: reduce intro redraws and stabilize trace scrolling - redraw only changed intro rows while preserving blinking cursors - keep trace scrolling speed independent of the configured frame rate - test idle redraws, cursor updates and scrolling parity --- .../NativeMatrixRenderer.swift | 84 +++++++++---------- .../NativeMatrixRendererTests.swift | 80 ++++++++++++++++++ 2 files changed, 121 insertions(+), 43 deletions(-) diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 5e87f65..8516729 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -219,7 +219,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 @@ -335,6 +334,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. @@ -737,7 +737,6 @@ final class NativeMatrixRenderer { } if numberIntroPhase == previousPhase { break } } - markAllRowsDirty() } private func stepCursorBlink(sceneTime: TimeInterval) { @@ -810,34 +809,22 @@ 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 baseGreenLevel = max(levelColors.count / 2, 1) - let scalars = Array(text.unicodeScalars) - let offset = m * columns - let colStart = m - let colEnd = columns - m - for col in colStart.. Date: Thu, 29 Oct 2026 15:55:46 +0100 Subject: [PATCH 07/10] feat: add shuffled command playback and output holds - animate command entry and display live output immediately - persist output holds and synchronized shuffled playback - update the shared dialog title and regression coverage --- README.md | 10 +- .../MatrixScreenSaver/CmdCaptureScene.swift | 111 +++++++++-------- .../MatrixScreenSaverOptions.swift | 92 ++++++++------ .../NativeMatrixRenderer.swift | 57 +++++---- .../CmdCaptureSceneTests.swift | 117 ++++++++++-------- .../NativeMatrixRendererTests.swift | 61 +++++++-- .../SharedSaverTests.swift | 58 ++++++--- 7 files changed, 315 insertions(+), 191 deletions(-) diff --git a/README.md b/README.md index ab03f76..f8fb2d6 100644 --- a/README.md +++ b/README.md @@ -41,17 +41,17 @@ 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 | -| Typing speed | Typing speed multiplier for Neo messages and the trace program. A positive number; message holds keep their natural duration. | 1.0 | +| 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 | Execute custom commands and type live output into consecutive rows as it arrives. 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 | -| Custom typing speed | Independent positive speed multiplier in the custom fieldset. 1 = 25 characters/second; use e.g. 40 for 1,000 characters/second. No pauses between output rows; final output holds briefly. | 10 | -| command playback | Radio buttons select all commands in sequence or one random command on each scene entry (all displays agree on the random pick). Add, edit, remove, or drag command rows to change their execution order in **Edit…**. Sequence mode finishes each command and its message playback before running the next. | One random command | +| 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 output: it is visible on the screen saver, +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. diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift index 94b60de..b2bcad9 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -3,19 +3,19 @@ import Darwin /// The drawing state snapshot the view uses to render the current cmd capture frame. struct CmdCaptureRenderState { - /// Visible output rows, including the partially typed bottom row. + /// Visible command and output rows, clipped to the scrolling viewport. let lines: [String] - /// Launching indicator, absent as soon as output arrives. - let statusLine: String? /// Uses the same blink cadence as Neo messages, including while waiting for output. let cursorVisible: Bool } -/// Types live command output into a bounded viewport, scrolling rather than clearing between rows. +/// Types a command with trace timing, then presents live output without a typing delay. struct CmdCaptureScene { /// Stages of capture presentation. enum Phase: Equatable { - /// Waiting for a background capture result. + /// 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 @@ -25,22 +25,27 @@ struct CmdCaptureScene { /// Default deadline for bounded standalone captures; live scene commands run until exit or cancellation. static let captureTimeout: TimeInterval = 12.0 - /// Indicator shown while the background task runs. - static let statusLineText = "Querying..." - /// 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 typedCharacters: Double = 0 - private var captureFinished = false private var holdStart: TimeInterval? private var viewportRows = 1 - private var characterCount = 0 - private var speedFactor: Double = 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? @@ -51,20 +56,33 @@ struct CmdCaptureScene { /// - Parameters: /// - startTime: Reference-relative time the scene begins. /// - command: Command to capture. Empty skips straight to `.done`. - /// - speedFactor: Custom typing multiplier; 1 types 25 characters per second. - mutating func reset(startTime: TimeInterval, command: String, speedFactor: Double = 1) { + /// - 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.speedFactor = speedFactor.isFinite && speedFactor > 0 ? speedFactor : 1 - typedCharacters = 0 + 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 - captureFinished = false holdStart = nil lines = [] scheduledDuration = nil - phase = command.isEmpty ? .done : .launching + phase = command.isEmpty ? .done : .typingCommand } - /// Applies the latest bounded output snapshot without restarting its typing progress. + /// Publishes output immediately in the viewport cleared after command typing. /// /// - Parameters: /// - output: Captured command output. @@ -74,23 +92,20 @@ struct CmdCaptureScene { /// - 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 != .done, !captureFinished else { return } - if phase == .presenting { advance(now: now) } + 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)))) } - characterCount = lines.reduce(0) { $0 + $1.unicodeScalars.count } + max(lines.count - 1, 0) - typedCharacters = min(typedCharacters, Double(characterCount)) lastAdvanceTime = now - captureFinished = isComplete if isComplete { - scheduledDuration = max(now - startTime, 0) + (Double(characterCount) - typedCharacters) * 0.04 / speedFactor + 2.5 - if typedCharacters >= Double(characterCount) { holdStart = now } + scheduledDuration = max(now - startTime, 0) + holdDuration + holdStart = now } phase = .presenting } @@ -104,46 +119,38 @@ struct CmdCaptureScene { if let holdStart { self.holdStart = holdStart + duration } } - /// Advances the current message using the scene's reference-relative clock. + /// 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 == .launching { - lastAdvanceTime = now - return - } - let needed = max(Double(characterCount) - typedCharacters, 0) - let progress = max(0, now - lastAdvanceTime) * 25 * speedFactor - typedCharacters = min(Double(characterCount), typedCharacters + progress) - if captureFinished, typedCharacters >= Double(characterCount) { - if holdStart == nil { holdStart = lastAdvanceTime + needed * 0.04 / speedFactor } - if now >= holdStart! + 2.5 { phase = .done } + 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 cursorVisible = Int(max(0, lastAdvanceTime - startTime) / NeoMessageScene.cursorBlinkInterval) % 2 == 0 + 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: [], statusLine: nil, cursorVisible: false) + return CmdCaptureRenderState(lines: [], cursorVisible: false) + case .typingCommand: + return CmdCaptureRenderState(lines: [commandLine], cursorVisible: cursorVisible) case .launching: - return CmdCaptureRenderState(lines: [], statusLine: Self.statusLineText, cursorVisible: cursorVisible) + return CmdCaptureRenderState(lines: [""], cursorVisible: cursorVisible) case .presenting: - var remaining = floor(typedCharacters) - var currentRow = 0 - while currentRow < lines.count - 1 { - let count = lines[currentRow].unicodeScalars.count - if remaining < Double(count + 1) { break } - remaining -= Double(count + 1) - currentRow += 1 - } - var visible = Array(lines[max(0, currentRow - viewportRows + 1).. 0 ? cmdTypingSpeedFactor : Self.defaultCmdTypingSpeedFactor, + cmdOutputHoldSeconds: cmdOutputHoldSeconds.isFinite && cmdOutputHoldSeconds >= 0 ? cmdOutputHoldSeconds : Self.defaultCmdOutputHoldSeconds, commands: sanitizedCommands, commandsRunInSequence: commandsRunInSequence, + commandsShuffleSequence: commandsShuffleSequence, numberSceneEnabled: numberSceneEnabled, twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, @@ -218,9 +226,10 @@ struct MatrixScreenSaverOptions: Equatable { Keys.name: defaultName, Keys.neoMessageLines: defaultNeoMessageLines, Keys.cmdSceneEnabled: defaultCmdSceneEnabled, - Keys.cmdTypingSpeedFactor: defaultCmdTypingSpeedFactor, + Keys.cmdOutputHoldSeconds: defaultCmdOutputHoldSeconds, Keys.commands: defaultCommands, Keys.commandsRunInSequence: defaultCommandsRunInSequence, + Keys.commandsShuffleSequence: false, Keys.numberSceneEnabled: defaultNumberSceneEnabled, Keys.twinkleEnabled: defaultTwinkleEnabled, Keys.diffuseEnabled: defaultDiffuseEnabled, @@ -245,9 +254,10 @@ struct MatrixScreenSaverOptions: Equatable { name: defaults.string(forKey: Keys.name) ?? defaultName, neoMessageLines: (defaults.array(forKey: Keys.neoMessageLines) as? [String]) ?? defaultNeoMessageLines, cmdSceneEnabled: defaults.bool(forKey: Keys.cmdSceneEnabled), - cmdTypingSpeedFactor: defaults.double(forKey: Keys.cmdTypingSpeedFactor), + 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), @@ -272,9 +282,10 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.name, forKey: Keys.name) defaults.set(options.neoMessageLines, forKey: Keys.neoMessageLines) defaults.set(options.cmdSceneEnabled, forKey: Keys.cmdSceneEnabled) - defaults.set(options.cmdTypingSpeedFactor, forKey: Keys.cmdTypingSpeedFactor) + 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) @@ -297,7 +308,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, NSTableViewDataSource, NSTableViewDelegate { private enum ValidationError: LocalizedError { case neoMessageSpeedFactor - case cmdTypingSpeedFactor + case cmdOutputHoldSeconds case rainDensity case frameRate case errorRate @@ -307,8 +318,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, switch self { case .neoMessageSpeedFactor: return "Intro typing speed must be a positive number." - case .cmdTypingSpeedFactor: - return "Custom typing speed must be a finite 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: @@ -329,7 +340,7 @@ 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 cmdTypingSpeedFactorField = NSTextField(string: "") + private let cmdOutputHoldSecondsField = NSTextField(string: "") private let nameField = NSTextField(string: "") private let linesTableView = NSTableView() private let linesScrollView = NSScrollView() @@ -365,6 +376,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, 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) @@ -425,13 +438,14 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, func prepare(using options: MatrixScreenSaverOptions) { neoMessageSceneCheckbox.state = options.neoMessageSceneEnabled ? .on : .off neoMessageSpeedFactorField.stringValue = Self.format(options.neoMessageSpeedFactor) - cmdTypingSpeedFactorField.stringValue = Self.format(options.cmdTypingSpeedFactor) + 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 ? .on : .off + 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 @@ -498,15 +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 - cmdTypingSpeedFactorField.formatter = Self.numberFormatter - cmdTypingSpeedFactorField.delegate = self + cmdOutputHoldSecondsField.formatter = Self.numberFormatter + cmdOutputHoldSecondsField.delegate = self rainDensityField.formatter = Self.numberFormatter rainDurationField.formatter = Self.integerFormatter frameRateField.formatter = Self.numberFormatter @@ -665,10 +679,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, /// Refreshes editable controls without changing the scene's retained values or enable toggle. private func updateSceneControls() { - cmdTypingSpeedFactorField.isEnabled = cmdSceneCheckbox.state == .on + cmdOutputHoldSecondsField.isEnabled = cmdSceneCheckbox.state == .on nameField.isEnabled = neoMessageSceneCheckbox.state == .on linesEditButton.isEnabled = neoMessageSceneCheckbox.state == .on - for control in [commandsEditButton, commandsSequenceRadio, commandsRandomRadio] { + for control in [commandsEditButton, commandsSequenceRadio, commandsRandomRadio, commandsShuffleRadio] { control.isEnabled = cmdSceneCheckbox.state == .on } } @@ -688,19 +702,21 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, label.widthAnchor.constraint(equalToConstant: 100).isActive = true let heading = NSStackView(views: [label, commandsEditButton]) heading.spacing = 12 - for radio in [commandsSequenceRadio, commandsRandomRadio] { + for radio in [commandsSequenceRadio, commandsRandomRadio, commandsShuffleRadio] { radio.target = self radio.action = #selector(commandModeChanged(_:)) } - let modes = NSStackView(views: [commandsSequenceRadio, commandsRandomRadio]) - modes.spacing = 12 + 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: "Typing speed", field: cmdTypingSpeedFactorField, - description: MatrixScreenSaverOptions.cmdTypingSpeedFactorDescription), + makeNumericSection(title: "Output hold", field: cmdOutputHoldSecondsField, + description: MatrixScreenSaverOptions.cmdOutputHoldSecondsDescription), ]) - cmdTypingSpeedFactorField.setAccessibilityLabel("Custom typing speed") + cmdOutputHoldSecondsField.setAccessibilityLabel("Command output hold in seconds") stack.orientation = .vertical stack.alignment = .leading stack.spacing = 4 @@ -711,6 +727,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, @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. @@ -1347,9 +1364,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let sizeIndex = Int(characterSizeSlider.doubleValue.rounded()) let sizePair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) - let cmdTypingSpeedFactor = try parseDouble(from: cmdTypingSpeedFactorField, error: .cmdTypingSpeedFactor) - guard cmdTypingSpeedFactor.isFinite, cmdTypingSpeedFactor > 0 else { - throw ValidationError.cmdTypingSpeedFactor + 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) @@ -1378,9 +1395,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, name: nameField.stringValue, neoMessageLines: pendingLines, cmdSceneEnabled: cmdSceneCheckbox.state == .on, - cmdTypingSpeedFactor: cmdTypingSpeedFactor, + cmdOutputHoldSeconds: cmdOutputHoldSeconds, commands: pendingCommands, - commandsRunInSequence: commandsSequenceRadio.state == .on, + 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/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 8516729..51ec0e6 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -5,11 +5,13 @@ final class NativeMatrixRenderer { var neoMessageSceneEnabled = true var neoMessageSpeedFactor = 1.0 var cmdSceneEnabled = false - /// Independent custom output typing multiplier. - var cmdTypingSpeedFactor = 10.0 + /// 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 @@ -32,11 +34,12 @@ final class NativeMatrixRenderer { neoMessageSceneEnabled: neoMessageSceneEnabled, neoMessageSpeedFactor: max(neoMessageSpeedFactor, MatrixRendererLimits.minimumNeoMessageSpeedFactor), cmdSceneEnabled: cmdSceneEnabled, - cmdTypingSpeedFactor: cmdTypingSpeedFactor.isFinite && cmdTypingSpeedFactor > 0 ? cmdTypingSpeedFactor : 10, + 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, @@ -525,9 +528,10 @@ final class NativeMatrixRenderer { rows > 0 && (previousConfiguration.neoMessageSceneEnabled != sanitizedConfiguration.neoMessageSceneEnabled || previousConfiguration.cmdSceneEnabled != sanitizedConfiguration.cmdSceneEnabled || - previousConfiguration.cmdTypingSpeedFactor != sanitizedConfiguration.cmdTypingSpeedFactor || + previousConfiguration.cmdOutputHoldSeconds != sanitizedConfiguration.cmdOutputHoldSeconds || previousConfiguration.commands != sanitizedConfiguration.commands || previousConfiguration.commandsRunInSequence != sanitizedConfiguration.commandsRunInSequence || + previousConfiguration.commandsShuffleSequence != sanitizedConfiguration.commandsShuffleSequence || previousConfiguration.numberSceneEnabled != sanitizedConfiguration.numberSceneEnabled || previousConfiguration.neoMessageLines != sanitizedConfiguration.neoMessageLines) @@ -1025,7 +1029,6 @@ final class NativeMatrixRenderer { } } - /// Launches one cancellable capture only when this scene enters active playback. private func beginCmdCaptureScene() { activeScene = .cmdCapture guard running else { return } @@ -1034,23 +1037,32 @@ final class NativeMatrixRenderer { for i in visibleCountByRow.indices { visibleCountByRow[i] = 0 } markAllRowsDirty() - // Pick one command from the configured list using a seed derived from the shared - // scene seed, so every display in a multi-monitor setup picks the same command, - // while a fresh restart cycle (new sceneSeed) is free to pick a different one. + // 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 - ? configuration.commands[commandIndex] - : configuration.commands.randomElement(using: &commandPickerRNG) ?? "" + ? commands[commandIndex] + : commands.randomElement(using: &commandPickerRNG) ?? "" - cmdScene.reset(startTime: Date.timeIntervalSinceReferenceDate, command: command, - speedFactor: configuration.cmdTypingSpeedFactor) - guard cmdScene.phase != .done else { return } + 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 @@ -1077,17 +1089,16 @@ final class NativeMatrixRenderer { 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 - if let statusLine = state.statusLine { - setRow0(text: statusLine, typedCount: statusLine.unicodeScalars.count, showCursor: state.cursorVisible) - } else { - let m = Self.numberIntroMargin - for row in m.. '\(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) @@ -251,6 +260,8 @@ private func commandCaptureLifecycleTests() { 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 } @@ -278,7 +289,7 @@ private func commandCaptureLifecycleTests() { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.05)) renderer.advance() ok(!displaysCapture() && !renderer.consumeCmdSceneDone(), - "Command capture: completion during suspension starts with a blank viewport before typing on resume") + "Command capture: suspension during command typing does not skip to execution on resume") renderer.stop() } @@ -289,10 +300,13 @@ private func customTraceHandoffTests() { 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) @@ -325,11 +339,13 @@ private func customLiveOutputTests() { configuration.neoMessageSceneEnabled = false configuration.numberSceneEnabled = false configuration.cmdSceneEnabled = true - configuration.cmdTypingSpeedFactor = 100 + 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 { @@ -338,7 +354,7 @@ private func customLiveOutputTests() { let deadline = Date(timeIntervalSinceNow: 1) var first = false var sawCursor = false - while !first && Date() < deadline { + while (!first || !sawCursor) && Date() < deadline { RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01)) renderer.advance() first = row(m).hasPrefix("FIRST") @@ -371,7 +387,8 @@ private func customScrollingViewportTests() { configuration.neoMessageSceneEnabled = false configuration.numberSceneEnabled = false configuration.cmdSceneEnabled = true - configuration.cmdTypingSpeedFactor = 100 + 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) @@ -388,13 +405,14 @@ private func customScrollingViewportTests() { 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 every prior output row") + 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. @@ -412,6 +430,8 @@ private func customShellCommandSequenceTests() { "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 { @@ -420,6 +440,7 @@ private func customShellCommandSequenceTests() { 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() } @@ -435,10 +456,36 @@ private func customShellCommandSequenceTests() { 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/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift index 40d8740..8400464 100644 --- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift +++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift @@ -72,13 +72,16 @@ func sharedSaverTests() { defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() } MatrixScreenSaverOptions.registerDefaults(in: defaults) var options = MatrixScreenSaverOptions.load(from: defaults) - check(options.cmdTypingSpeedFactor == 10 && MatrixScreenSaverOptions().cmdTypingSpeedFactor == 10 - && NativeMatrixRenderer.Configuration().cmdTypingSpeedFactor == 10, - "custom typing speed defaults to ten in persistence, options and the renderer") - options.cmdTypingSpeedFactor = 1 + 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).cmdTypingSpeedFactor == 1, - "the new custom speed default preserves an explicitly saved value") + check(MatrixScreenSaverOptions.load(from: defaults).cmdOutputHoldSeconds == 0, + "output hold preserves an explicitly saved zero") options.frameRate = 42 options.neoMessageLines = ["Shared options"] options.name = "Trinity" @@ -87,14 +90,25 @@ func sharedSaverTests() { options.numberSceneEnabled = false options.commands = ["printf first", "printf second"] options.commandsRunInSequence = true + options.commandsShuffleSequence = true options.neoMessageSpeedFactor = 2 - options.cmdTypingSpeedFactor = 40 + 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" } @@ -105,8 +119,10 @@ func sharedSaverTests() { "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.cmdTypingSpeedFactor == 40 && loaded.rendererConfiguration().cmdTypingSpeedFactor == 40, - "tabbed options preserve the Neo name and command settings through Apply and persistence") + && 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) @@ -123,15 +139,18 @@ func sharedSaverTests() { 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, 0, -1] { - commandOptions.cmdTypingSpeedFactor = value - check(commandOptions.sanitized().cmdTypingSpeedFactor == 10, - "custom speed rejects nonfinite and nonpositive persisted values") + 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.cmdTypingSpeedFactor = 100 + commandOptions.cmdOutputHoldSeconds = 100 check(commandOptions.requiresSceneRestart(comparedTo: original), - "custom typing speed changes restart all participating displays") + "output hold changes restart all participating displays") var live = original live.twinkleEnabled.toggle() live.diffuseEnabled.toggle() @@ -151,6 +170,7 @@ func sharedSaverTests() { ("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 }), @@ -253,6 +273,7 @@ private func glyphCacheTests() { capturedOptions.cmdSceneEnabled = true capturedOptions.numberSceneEnabled = false capturedOptions.commands = ["printf '\\303\\251'"] + capturedOptions.neoMessageSpeedFactor = 100 capturedOptions.scanLinesIntensity = 0 let container = NSView(frame: view.frame) container.addSubview(view) @@ -260,7 +281,7 @@ private func glyphCacheTests() { view.startAnimation() view.applyWallpaperOptions(capturedOptions) let renderer: NativeMatrixRenderer? = stored("nativeRenderer", in: view) - let captured = eventually { + let captured = eventually(8) { view.animateOneFrame() return renderer?[NativeMatrixRenderer.contentMargin, NativeMatrixRenderer.contentMargin].scalar == "é" } @@ -336,8 +357,9 @@ private func checkSceneOptions(_ controller: MatrixScreenSaverOptionsSheetContro } 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() == "Custom typing speed" && $0.stringValue == "40" }, - "custom fieldset owns an independent typing speed control") + 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" }, From 53d0d57dce43922774fa2710591c9a5bbfc56289 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 29 Oct 2026 15:55:53 +0100 Subject: [PATCH 08/10] fix: enable standard editing shortcuts in wallpaper dialogs - route copy, paste, cut and select all through an Edit menu - verify shortcut actions with the native options field editor --- Sources/MatrixWallpaper/WallpaperApp.swift | 17 +++++++ .../WallpaperAppTests.swift | 47 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift index df09517..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) diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift index 1304e42..b42953b 100644 --- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift +++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift @@ -153,6 +153,9 @@ func wallpaperAppTests() { 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) @@ -171,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)" From 477408264f7b45f6405d32ca2786a01d1f6635e2 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 29 Oct 2026 16:06:46 +0100 Subject: [PATCH 09/10] fix: make shell argument conversion compatible with CI Swift - duplicate shell arguments and environment through explicit C strings - preserve allocated pointer lifetimes through process launch --- Sources/MatrixScreenSaver/CmdCaptureScene.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift index b2bcad9..cfe83b7 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -248,8 +248,8 @@ final class CmdCaptureTask { 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 { strdup($0) } - let variables = environment.map { strdup("\($0.key)=\($0.value)") } + 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] From 8d8ad97e46ece42456e354255603744b70d954d9 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 29 Oct 2026 16:16:09 +0100 Subject: [PATCH 10/10] fix: reliably reap cancelled shell processes - wait for child reaping after the background exit notification - repeat cancellation coverage to catch the macOS exit race --- .../MatrixScreenSaver/CmdCaptureScene.swift | 3 +- .../CmdCaptureSceneTests.swift | 36 +++++++++++-------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/Sources/MatrixScreenSaver/CmdCaptureScene.swift b/Sources/MatrixScreenSaver/CmdCaptureScene.swift index cfe83b7..c77136b 100644 --- a/Sources/MatrixScreenSaver/CmdCaptureScene.swift +++ b/Sources/MatrixScreenSaver/CmdCaptureScene.swift @@ -271,7 +271,8 @@ final class CmdCaptureTask { let exitSource = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: .global()) exitSource.setEventHandler { var status: Int32 = 0 - _ = waitpid(pid, &status, WNOHANG) + // 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() diff --git a/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift index 2502946..36bce44 100644 --- a/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift +++ b/Tests/MatrixScreenSaverTests/CmdCaptureSceneTests.swift @@ -183,23 +183,29 @@ func cmdCaptureTaskTests() { cancelled.cancel() ok(cancelled.captureOutput(command: "printf bad > '\(marker)'").isEmpty && !FileManager.default.fileExists(atPath: marker), "CmdCaptureTask: cancellation before launch prevents side effects") - let running = CmdCaptureTask() - let finished = DispatchSemaphore(value: 0) let command = "trap '' TERM; (trap '' TERM; printf '%s' $$ > '\(ready)'; /bin/sleep 0.7; printf escaped > '\(late)') | /bin/cat; wait" - DispatchQueue.global().async { - _ = running.captureOutput(command: command, timeout: 3) - finished.signal() - } - let readyDeadline = ProcessInfo.processInfo.systemUptime + 2 - while !FileManager.default.fileExists(atPath: ready) && ProcessInfo.processInfo.systemUptime < readyDeadline { - Thread.sleep(forTimeInterval: 0.01) + // 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") } - ok(FileManager.default.fileExists(atPath: ready), "CmdCaptureTask: descendant pipeline starts") - running.cancel() - ok(finished.wait(timeout: .now() + 1.5) == .success, "CmdCaptureTask: cancellation of TERM-refusing pipeline is bounded") - let pidText = (try? String(contentsOfFile: ready, encoding: .utf8)) ?? "" - let pid = Int32(pidText) ?? 0 - 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")