diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 324fd7a..fd839c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,6 +34,8 @@ jobs: if: github.event_name == 'workflow_dispatch' name: Prepare release PR runs-on: macos-15 + outputs: + branch: ${{ steps.version.outputs.branch }} env: GH_TOKEN: ${{ github.token }} @@ -43,13 +45,13 @@ jobs: with: fetch-depth: 0 - - name: Ensure release runs from main + - name: Ensure release runs from development shell: bash run: | set -euo pipefail - if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then - echo "Run this workflow from main." >&2 + if [[ "${GITHUB_REF}" != "refs/heads/development" ]]; then + echo "Run this workflow from development." >&2 exit 1 fi @@ -86,20 +88,16 @@ jobs: exit 1 fi - printf '%s\n' "${next_version}" > VERSION - echo "current=${current_version}" >> "$GITHUB_OUTPUT" echo "next=${next_version}" >> "$GITHUB_OUTPUT" echo "branch=${release_branch}" >> "$GITHUB_OUTPUT" - name: Inspect release refs - id: refs shell: bash run: | set -euo pipefail version="${{ steps.version.outputs.next }}" - branch="${{ steps.version.outputs.branch }}" if git ls-remote --exit-code --tags origin "refs/tags/${version}" >/dev/null 2>&1; then echo "Tag ${version} already exists." >&2 @@ -111,11 +109,21 @@ jobs: exit 1 fi - if git ls-remote --exit-code --heads origin "${branch}" >/dev/null 2>&1; then - echo "branch_exists=true" >> "$GITHUB_OUTPUT" - else - echo "branch_exists=false" >> "$GITHUB_OUTPUT" - fi + - name: Create release branch + shell: bash + run: | + set -euo pipefail + + branch="${{ steps.version.outputs.branch }}" + + # Always (re)create from development so the release includes all + # new changes — even on a rerun where the branch already exists. + git fetch origin development + git switch -c "${branch}" origin/development + + # Merge main to incorporate any commits not on development + # (e.g. previous release chore commits) + git merge origin/main --no-edit - name: Validate build shell: bash @@ -138,7 +146,9 @@ jobs: fi today="$(date -u +%Y-%m-%d)" - # Collect conventional commits since the previous tag + # Collect conventional commits since the previous tag. + # --first-parent follows only the mainline (one entry per merged PR, + # no duplicates from feature branch commits). features="" fixes="" while IFS= read -r line; do @@ -154,7 +164,7 @@ jobs: rest="${msg#fix(}" fixes="${fixes}- ${rest#*): }"$'\n' fi - done < <(git log "${prev_tag}..HEAD" --oneline --no-merges 2>/dev/null || git log --oneline --no-merges) + done < <(git log "${prev_tag}..HEAD" --oneline --first-parent 2>/dev/null || git log --oneline --first-parent) # Build the new changelog section new_section="## [${version}] - ${today}"$'\n' @@ -174,36 +184,20 @@ jobs: } > "${tmp}" mv "${tmp}" CHANGELOG.md - - name: Create or reuse release branch + - name: Commit and push release changes shell: bash run: | set -euo pipefail version="${{ steps.version.outputs.next }}" branch="${{ steps.version.outputs.branch }}" - branch_exists="${{ steps.refs.outputs.branch_exists }}" - - if [[ "${branch_exists}" == "true" ]]; then - git restore --source=HEAD --staged --worktree VERSION CHANGELOG.md - git fetch origin "${branch}" - git switch -C "${branch}" "origin/${branch}" - - branch_version="$(tr -d '[:space:]' < VERSION)" - if [[ "${branch_version}" != "${version}" ]]; then - echo "Existing ${branch} has VERSION ${branch_version}, expected ${version}." >&2 - exit 1 - fi - - exit 0 - fi - git switch -c "${branch}" printf '%s\n' "${version}" > VERSION git add VERSION CHANGELOG.md git commit -m "chore(release): ${version}" -m "- bump VERSION to ${version}" -m "- update CHANGELOG.md" - git push --set-upstream origin "${branch}" + git push --force-with-lease --set-upstream origin "${branch}" - - name: Open or reuse release pull request + - name: Open release pull request id: pr shell: bash run: | @@ -219,13 +213,16 @@ jobs: printf '## What'\''s changing\n\n%s\n\n## Validation\n- `./build.sh`\n' "${changelog_section}" )" - pr_url="$(gh pr list --head "${branch}" --base main --state open --json url --jq '.[0].url')" - if [[ -z "${pr_url}" || "${pr_url}" == "null" ]]; then - pr_url="$(gh pr create --base main --head "${branch}" --title "chore(release): ${version}" --body "${pr_body}")" - else - gh pr edit "${pr_url}" --body "${pr_body}" + # Close any existing release PR so the new one starts with a clean + # check-suite (reused PRs carry stale required-check expectations + # that GITHUB_TOKEN workflows can never satisfy). + existing="$(gh pr list --head "${branch}" --base main --state open --json number --jq '.[0].number')" + if [[ -n "${existing}" && "${existing}" != "null" ]]; then + gh pr close "${existing}" --delete-branch=false --comment "Superseded by a new release run." fi + pr_url="$(gh pr create --base main --head "${branch}" --title "chore(release): ${version}" --body "${pr_body}")" + echo "url=${pr_url}" >> "$GITHUB_OUTPUT" - name: Write pull request summary @@ -237,6 +234,41 @@ jobs: printf 'Merge that pull request into main to trigger the publish job.\n' } >> "$GITHUB_STEP_SUMMARY" + # GITHUB_TOKEN pushes don't trigger other workflows, so the required + # checks ("Run tests", "Lint commit messages") would stay pending on the + # release PR. These jobs run the same checks inline with matching names + # so branch protection on main is satisfied. + release-tests: + needs: prepare + if: github.event_name == 'workflow_dispatch' + name: Run tests + runs-on: macos-latest + steps: + - name: Check out release branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.branch }} + + - name: Run tests + run: ./tests.sh + + release-lint: + needs: prepare + if: github.event_name == 'workflow_dispatch' + name: Lint commit messages + runs-on: ubuntu-latest + steps: + - name: Check out release branch + uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.branch }} + fetch-depth: 0 + + - name: Lint commits + uses: wagoid/commitlint-github-action@v6 + with: + configFile: .commitlintrc.json + publish: if: github.event_name == 'push' name: Publish release diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift index ff244b8..ca23bfe 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift @@ -12,7 +12,9 @@ struct MatrixScreenSaverOptions: Equatable { static let characterWidth = "CharacterWidth" static let characterHeight = "CharacterHeight" static let rainDensity = "RainDensity" - static let frameRate = "FrameRate" + static let rainRunForever = "RainRunForever" + static let rainDurationSeconds = "RainDurationSeconds" + static let frameRate = "FrameRate" static let errorRate = "ErrorRate" static let characters = "Characters" static let scanLinesIntensity = "ScanLinesIntensity" @@ -27,8 +29,10 @@ struct MatrixScreenSaverOptions: Equatable { static let defaultDiffuseEnabled = true static let defaultCharacterWidth = 16 static let defaultCharacterHeight = 30 - static let defaultRainDensity = 1.0 - static let defaultFrameRate = 25.0 + static let defaultRainDensity = 1.0 + static let defaultRainRunForever = true + static let defaultRainDurationSeconds = 300 + static let defaultFrameRate = 25.0 static let defaultErrorRate = 1.0 static let defaultCharacters = "" static let defaultScanLinesIntensity = 0.25 @@ -41,9 +45,11 @@ struct MatrixScreenSaverOptions: Equatable { static let minimumNeoMessageSpeedFactor = MatrixRendererLimits.minimumNeoMessageSpeedFactor static let minimumCharacterWidth = 1 static let minimumCharacterHeight = 1 - static let minNeoMessageLineCount = 1 - static let maxNeoMessageLineCount = 10 - static let maxNeoMessageLineLength = 256 + static let minNeoMessageLineCount = 1 + static let maxNeoMessageLineCount = 10 + static let maxNeoMessageLineLength = 256 + static let minimumRainDurationSeconds = 1 + static let maximumRainDurationSeconds = 9999 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." @@ -57,8 +63,10 @@ struct MatrixScreenSaverOptions: Equatable { (8, 15), (9, 17), (10, 19), (11, 21), (12, 23), (13, 24), (14, 26), (15, 28), (16, 30), (17, 32), (18, 34), (19, 36), (20, 38), (21, 40), (22, 42), (23, 44), (24, 46) ] static let defaultCharacterSizeIndex = 8 - static let rainDensityDescription = "Rain density multiplier. Default: 1.0." - static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." + static let rainDensityDescription = "Rain density multiplier. Default: 1.0." + static let rainRunForeverDescription = "When enabled (default), rain continuously spawns new falling lines. When disabled, rain stops starting new lines after the specified duration; lines already falling finish and fade out, then the full scene sequence restarts." + static let rainDurationSecondsDescription = "How long rain keeps starting new falling lines (1–9999 seconds) before it winds down. Default: 300 (5 minutes)." + static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25." static let errorRateDescription = "Character change rate factor. Default: 1.0." static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default." static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%." @@ -71,8 +79,10 @@ struct MatrixScreenSaverOptions: Equatable { var diffuseEnabled = defaultDiffuseEnabled var characterWidth = defaultCharacterWidth var characterHeight = defaultCharacterHeight - var rainDensity = defaultRainDensity - var frameRate = defaultFrameRate + var rainDensity = defaultRainDensity + var rainRunForever = defaultRainRunForever + var rainDurationSeconds = defaultRainDurationSeconds + var frameRate = defaultFrameRate var errorRate = defaultErrorRate var characters = defaultCharacters var scanLinesIntensity = defaultScanLinesIntensity @@ -87,7 +97,9 @@ struct MatrixScreenSaverOptions: Equatable { twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, rainDensity: rainDensity, - frameRate: frameRate, + rainRunForever: rainRunForever, + rainDurationSeconds: rainDurationSeconds, + frameRate: frameRate, errorRate: errorRate, characters: characters, neoMessageLines: neoMessageLines, @@ -114,8 +126,10 @@ struct MatrixScreenSaverOptions: Equatable { diffuseEnabled: diffuseEnabled, characterWidth: max(characterWidth, Self.minimumCharacterWidth), characterHeight: max(characterHeight, Self.minimumCharacterHeight), - rainDensity: max(rainDensity, Self.minimumRainDensity), - frameRate: min(max(frameRate, Self.minimumFrameRate), Self.maximumFrameRate), + rainDensity: max(rainDensity, Self.minimumRainDensity), + rainRunForever: rainRunForever, + rainDurationSeconds: min(max(rainDurationSeconds, Self.minimumRainDurationSeconds), Self.maximumRainDurationSeconds), + frameRate: min(max(frameRate, Self.minimumFrameRate), Self.maximumFrameRate), errorRate: max(errorRate, Self.minimumErrorRate), characters: { var seen = Set() @@ -147,8 +161,10 @@ struct MatrixScreenSaverOptions: Equatable { Keys.diffuseEnabled: defaultDiffuseEnabled, Keys.characterWidth: defaultCharacterWidth, Keys.characterHeight: defaultCharacterHeight, - Keys.rainDensity: defaultRainDensity, - Keys.frameRate: defaultFrameRate, + Keys.rainDensity: defaultRainDensity, + Keys.rainRunForever: defaultRainRunForever, + Keys.rainDurationSeconds: defaultRainDurationSeconds, + Keys.frameRate: defaultFrameRate, Keys.errorRate: defaultErrorRate, Keys.characters: defaultCharacters, Keys.scanLinesIntensity: defaultScanLinesIntensity, @@ -167,8 +183,10 @@ struct MatrixScreenSaverOptions: Equatable { diffuseEnabled: defaults.bool(forKey: Keys.diffuseEnabled), characterWidth: defaults.integer(forKey: Keys.characterWidth), characterHeight: defaults.integer(forKey: Keys.characterHeight), - rainDensity: defaults.double(forKey: Keys.rainDensity), - frameRate: defaults.double(forKey: Keys.frameRate), + rainDensity: defaults.double(forKey: Keys.rainDensity), + rainRunForever: defaults.bool(forKey: Keys.rainRunForever), + rainDurationSeconds: defaults.integer(forKey: Keys.rainDurationSeconds), + frameRate: defaults.double(forKey: Keys.frameRate), errorRate: defaults.double(forKey: Keys.errorRate), characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters, scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity), @@ -187,8 +205,10 @@ struct MatrixScreenSaverOptions: Equatable { defaults.set(options.diffuseEnabled, forKey: Keys.diffuseEnabled) defaults.set(options.characterWidth, forKey: Keys.characterWidth) defaults.set(options.characterHeight, forKey: Keys.characterHeight) - defaults.set(options.rainDensity, forKey: Keys.rainDensity) - defaults.set(options.frameRate, forKey: Keys.frameRate) + defaults.set(options.rainDensity, forKey: Keys.rainDensity) + defaults.set(options.rainRunForever, forKey: Keys.rainRunForever) + defaults.set(options.rainDurationSeconds, forKey: Keys.rainDurationSeconds) + defaults.set(options.frameRate, forKey: Keys.frameRate) defaults.set(options.errorRate, forKey: Keys.errorRate) defaults.set(options.characters, forKey: Keys.characters) defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity) @@ -200,25 +220,28 @@ struct MatrixScreenSaverOptions: Equatable { final class MatrixScreenSaverOptionsSheetController: NSObject, NSTextFieldDelegate, NSWindowDelegate, NSTableViewDataSource, NSTableViewDelegate { - private enum ValidationError: LocalizedError { - case neoMessageSpeedFactor - case rainDensity - case frameRate - case errorRate - - var errorDescription: String? { - switch self { - case .neoMessageSpeedFactor: - return "Neo message speed must be a positive number." - case .rainDensity: - return "Rain density must be a positive number." - case .frameRate: - return "Frame rate must be a positive number less than or equal to 1000." - case .errorRate: - return "Error rate must be a non-negative number." - } - } - } + private enum ValidationError: LocalizedError { + case neoMessageSpeedFactor + case rainDensity + case frameRate + case errorRate + case rainDurationSeconds + + var errorDescription: String? { + switch self { + case .neoMessageSpeedFactor: + return "Neo message speed must be a positive number." + case .rainDensity: + return "Rain density must be a positive number." + case .frameRate: + return "Frame rate must be a positive number less than or equal to 1000." + case .errorRate: + return "Error rate must be a non-negative number." + case .rainDurationSeconds: + return "Rain duration must be a positive integer (1–9999 seconds)." + } + } + } private weak var owner: MatrixScreenSaverView? var onClose: (() -> Void)? @@ -270,8 +293,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let pair = MatrixScreenSaverOptions.characterSizePairs[MatrixScreenSaverOptions.defaultCharacterSizeIndex] return NSTextField(labelWithString: "\(pair.width)×\(pair.height)") }() - private let rainDensityField = NSTextField(string: "") - private let frameRateField = NSTextField(string: "") + private let rainDensityField = NSTextField(string: "") + private let rainRunForeverCheckbox = NSButton(checkboxWithTitle: "Run forever", target: nil, action: nil) + private let rainDurationField = NSTextField(string: "") + private let frameRateField = NSTextField(string: "") private let errorRateField = NSTextField(string: "") private let charactersField = NSTextField(string: "") private var didClose = false @@ -306,7 +331,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, characterSizeSlider.doubleValue = Double(sizeIndex) let pair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" - rainDensityField.stringValue = Self.format(options.rainDensity) + rainDensityField.stringValue = Self.format(options.rainDensity) + rainRunForeverCheckbox.state = options.rainRunForever ? .on : .off + rainDurationField.stringValue = Self.format(options.rainDurationSeconds) + rainDurationField.isEnabled = !options.rainRunForever frameRateField.stringValue = Self.format(options.frameRate) errorRateField.stringValue = Self.format(options.errorRate) charactersField.stringValue = options.characters @@ -357,16 +385,20 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, window.isReleasedWhenClosed = false window.delegate = self - characterSizeSlider.target = self - characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) - neoMessageSpeedFactorField.formatter = Self.numberFormatter - rainDensityField.formatter = Self.numberFormatter - frameRateField.formatter = Self.numberFormatter - errorRateField.formatter = Self.numberFormatter - neoMessageSpeedFactorField.delegate = self - rainDensityField.delegate = self - frameRateField.delegate = self - errorRateField.delegate = self + characterSizeSlider.target = self + characterSizeSlider.action = #selector(characterSizeSliderChanged(_:)) + neoMessageSpeedFactorField.formatter = Self.numberFormatter + rainDensityField.formatter = Self.numberFormatter + rainDurationField.formatter = Self.integerFormatter + frameRateField.formatter = Self.numberFormatter + errorRateField.formatter = Self.numberFormatter + neoMessageSpeedFactorField.delegate = self + rainDensityField.delegate = self + rainDurationField.delegate = self + frameRateField.delegate = self + errorRateField.delegate = self + rainRunForeverCheckbox.target = self + rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:)) rootStack.orientation = .vertical rootStack.alignment = .leading @@ -413,13 +445,14 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, let numberGroup = makeGroupBox(title: "Number scene", views: [ makeCheckboxSection(checkbox: numberSceneCheckbox, description: MatrixScreenSaverOptions.numberSceneDescription), ]) - let rainGroup = makeGroupBox(title: "Rain scene", views: [ - makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), - makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), - makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), - makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), - makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), - ]) + let rainGroup = makeGroupBox(title: "Rain scene", views: [ + makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription), + makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription), + makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription), + makeRainDurationSection(), + makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2), + makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription), + ]) rootStack.addArrangedSubview(generalGroup) rootStack.addArrangedSubview(neoGroup) rootStack.addArrangedSubview(numberGroup) @@ -933,6 +966,45 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } + /// Builds the rain duration section with "Run forever" checkbox and optional seconds field. + private func makeRainDurationSection() -> NSView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 4 + stack.setHuggingPriority(.required, for: .vertical) + + let checkboxRow = NSStackView() + checkboxRow.orientation = .horizontal + checkboxRow.spacing = 0 + checkboxRow.alignment = .centerY + rainRunForeverCheckbox.setButtonType(.switch) + checkboxRow.addArrangedSubview(rainRunForeverCheckbox) + + let secondsRow = NSStackView() + secondsRow.orientation = .horizontal + secondsRow.spacing = 12 + secondsRow.alignment = .centerY + + let secondsLabel = NSTextField(labelWithString: "Seconds") + secondsLabel.font = .systemFont(ofSize: 13, weight: .semibold) + secondsLabel.setContentHuggingPriority(.required, for: .horizontal) + + rainDurationField.alignment = .left + rainDurationField.controlSize = .regular + rainDurationField.translatesAutoresizingMaskIntoConstraints = false + rainDurationField.widthAnchor.constraint(equalToConstant: Self.fourDigitFieldWidth).isActive = true + + secondsRow.addArrangedSubview(secondsLabel) + secondsRow.addArrangedSubview(rainDurationField) + + stack.addArrangedSubview(checkboxRow) + stack.addArrangedSubview(secondsRow) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription)) + stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription)) + return stack + } + /// Builds the character size slider with a live size label. private func makeCharacterSizeSection() -> NSView { let stack = NSStackView() @@ -966,11 +1038,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, return stack } - @objc private func characterSizeSliderChanged(_ sender: NSSlider) { - let index = Int(sender.doubleValue.rounded()) - let pair = MatrixScreenSaverOptions.characterSizePairs[index] - characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" - } + @objc private func characterSizeSliderChanged(_ sender: NSSlider) { + let index = Int(sender.doubleValue.rounded()) + let pair = MatrixScreenSaverOptions.characterSizePairs[index] + characterSizeValueLabel.stringValue = "\(pair.width)×\(pair.height)" + } + + @objc private func rainRunForeverCheckboxChanged(_ sender: NSButton) { + let isRunningForever = sender.state == .on + rainDurationField.isEnabled = !isRunningForever + } /// Builds a titled group box containing a vertical stack of option sections. private func makeGroupBox(title: String, views: [NSView]) -> NSBox { @@ -1010,18 +1087,22 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, private func validatedOptions() throws -> MatrixScreenSaverOptions { let sizeIndex = Int(characterSizeSlider.doubleValue.rounded()) let sizePair = MatrixScreenSaverOptions.characterSizePairs[sizeIndex] - let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) - let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) - let frameRate = try parseDouble(from: frameRateField, error: .frameRate) + let neoMessageSpeedFactor = try parseDouble(from: neoMessageSpeedFactorField, error: .neoMessageSpeedFactor) + let rainDensity = try parseDouble(from: rainDensityField, error: .rainDensity) + let rainDurationSeconds = try parseInteger(from: rainDurationField, error: .rainDurationSeconds) + let frameRate = try parseDouble(from: frameRateField, error: .frameRate) let errorRate = try parseDouble(from: errorRateField, error: .errorRate) guard neoMessageSpeedFactor > 0 else { throw ValidationError.neoMessageSpeedFactor } - guard rainDensity > 0 else { - throw ValidationError.rainDensity - } - guard frameRate > 0, frameRate <= MatrixScreenSaverOptions.maximumFrameRate else { + guard rainDensity > 0 else { + throw ValidationError.rainDensity + } + guard rainDurationSeconds >= MatrixScreenSaverOptions.minimumRainDurationSeconds else { + throw ValidationError.rainDurationSeconds + } + guard frameRate > 0, frameRate <= MatrixScreenSaverOptions.maximumFrameRate else { throw ValidationError.frameRate } guard errorRate >= 0 else { @@ -1039,8 +1120,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject, diffuseEnabled: diffuseCheckbox.state == .on, characterWidth: sizePair.width, characterHeight: sizePair.height, - rainDensity: rainDensity, - frameRate: frameRate, + rainDensity: rainDensity, + rainRunForever: rainRunForeverCheckbox.state == .on, + rainDurationSeconds: rainDurationSeconds, + frameRate: frameRate, errorRate: errorRate, characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines), scanLinesIntensity: scanLinesSlider.doubleValue / 100, diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index 61b458f..ad20a59 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -40,6 +40,18 @@ final class MatrixScreenSaverView: ScreenSaverView { private var firstHiddenFrameTime: TimeInterval = 0 private static let hiddenFrameToleranceSeconds: TimeInterval = 5.0 + /// When `true`, the renderer start is deferred until the + /// ``ScreenSyncCoordinator`` resolves a shared scene start time. + private var pendingSync = false + + /// When `true`, a rain restart is deferred until the ``ScreenSyncCoordinator`` + /// resolves a shared restart time across all displays. + private var pendingRestart = false + + /// When `true`, a scene transition is deferred until the ``ScreenSyncCoordinator`` + /// resolves a shared phase advance time across all displays. + private var pendingSceneAdvance = false + private var windowRect = NSRect.zero private var titlebarRect = NSRect.zero private var terminalRect = NSRect.zero @@ -180,14 +192,29 @@ final class MatrixScreenSaverView: ScreenSaverView { updateScreenSaverLifecycleObservation() updateLayout() nativeRenderer.seedOffset = displaySeed - nativeRenderer.start() + + if !skipSyncDelay { + // Register with the coordinator and defer renderer start until + // all expected instances are ready (or the timeout elapses). + let screenCount = NSScreen.screens.count + ScreenSyncCoordinator.shared.register(ObjectIdentifier(self), expectedCount: screenCount) + pendingSync = true + NSLog("MatrixScreenSaver sync registered (%d/%d)", ScreenSyncCoordinator.shared.registeredCount, screenCount) + } else { + pendingSync = false + nativeRenderer.start() + } } /// Stops the renderer and releases temporary rendering resources. override func stopAnimation() { let wasActive = animationActive animationActive = false + pendingSync = false + pendingRestart = false + pendingSceneAdvance = false nativeRenderer.stop() + ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() if wasActive { NSLog("MatrixScreenSaver stopAnimation") @@ -211,10 +238,66 @@ final class MatrixScreenSaverView: ScreenSaverView { return } firstHiddenFrameTime = 0 + + // While waiting for multi-screen sync (either initial startup or rain restart), + // poll the coordinator each frame. Once a shared start time is available, + // apply it and start the renderer so all displays begin simultaneously. + if pendingSync { + let startTime: TimeInterval? + if pendingRestart { + startTime = ScreenSyncCoordinator.shared.resolvedRestartTime() + } else { + startTime = ScreenSyncCoordinator.shared.resolvedStartTime() + } + guard let time = startTime else { + return + } + pendingSync = false + if pendingRestart { + ScreenSyncCoordinator.shared.finishRestart(ObjectIdentifier(self)) + pendingRestart = false + NSLog("MatrixScreenSaver restart resolved — restarting rain (startTime=%.2f)", time) + } else { + NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", time) + } + nativeRenderer.overrideSceneStartTime = time + nativeRenderer.start() + } + + // While waiting for a scene advance (Neo→Number, Number→Rain), poll the coordinator + // each frame. Once all displays are ready, advance to the next scene. + if pendingSceneAdvance { + guard let _ = ScreenSyncCoordinator.shared.resolvedPhaseAdvanceTime() else { + return + } + pendingSceneAdvance = false + ScreenSyncCoordinator.shared.finishPhaseAdvance(ObjectIdentifier(self)) + nativeRenderer.advanceToNextScene() + NSLog("MatrixScreenSaver phase advance resolved — transitioning to next scene") + } + super.animateOneFrame() if nativeRenderer.advance() { needsDisplay = true } + + // After advance, check if the renderer has requested a restart + if nativeRenderer.consumeRestartRequest() { + ScreenSyncCoordinator.shared.requestRestart(ObjectIdentifier(self)) + pendingRestart = true + pendingSync = true + } + + // After advance, check if the renderer has completed a scene and needs to transition + if nativeRenderer.consumeNeoSceneDone() { + ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self)) + pendingSceneAdvance = true + NSLog("MatrixScreenSaver Neo scene complete — requesting phase advance") + } else if nativeRenderer.consumeNumberSceneDone() { + ScreenSyncCoordinator.shared.requestPhaseAdvance(ObjectIdentifier(self)) + pendingSceneAdvance = true + NSLog("MatrixScreenSaver Number scene complete — requesting phase advance") + } } /// Releases renderer resources when the saver view is torn down. @@ -222,6 +305,7 @@ final class MatrixScreenSaverView: ScreenSaverView { appendDebugLog("[\(debugIdentifier)] deinit") animationActive = false nativeRenderer.stop() + ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() } diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index b666aad..72176da 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -8,7 +8,9 @@ final class NativeMatrixRenderer { var twinkleEnabled = true var diffuseEnabled = true var rainDensity = 1.0 - var frameRate = 25.0 + var rainRunForever = true + var rainDurationSeconds = 300 + var frameRate = 25.0 var errorRate = 1.0 var characters = "" var neoMessageLines: [String] = NeoMessageScene.defaultLines @@ -26,7 +28,9 @@ final class NativeMatrixRenderer { twinkleEnabled: twinkleEnabled, diffuseEnabled: diffuseEnabled, rainDensity: max(rainDensity, MatrixRendererLimits.minimumRainDensity), - frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), + rainRunForever: rainRunForever, + rainDurationSeconds: max(rainDurationSeconds, 1), + frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate), errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate), characters: characters, neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.defaultLines : neoMessageLines, @@ -192,7 +196,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 rainDuration: TimeInterval = 90.0 // how long rain runs before restarting private static let numberIntroRainFrames = 160 private static let numberIntroBlackoutInterval: TimeInterval = 1.0 private static let numberIntroBlackoutRounds = 10 @@ -239,10 +242,17 @@ final class NativeMatrixRenderer { private(set) var rows = 0 private(set) var levelColors = NativeMatrixRenderer.palette - /// XORed into sceneSeed in beginSceneSequence so each physical display - /// gets an independent animation while preserving per-display determinism. + /// XORed into the rain RNG seed so each physical display gets independent + /// rain animation while the scripted scenes (neo message, number intro) + /// remain identical across displays. var seedOffset: UInt64 = 0 + /// When set, ``beginSceneSequence()`` uses this value instead of computing + /// a start time from the current wall clock. The ``ScreenSyncCoordinator`` + /// sets this so every display begins its scene sequence at the exact same + /// moment. + var overrideSceneStartTime: TimeInterval? + private var configuration = Configuration() private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool @@ -266,7 +276,11 @@ final class NativeMatrixRenderer { private var now = 100 private var frameIndex = 0 private var activeScene: Scene = .numberIntro - private enum NumberIntroPhase { + private var rainProducing = true + private var restartRequested = false + private var neoSceneDoneRequested = false + private var numberSceneDoneRequested = false + private enum NumberIntroPhase { case cursorBlink, typingFirstLine, pauseAfterFirst, secondLine, pauseAfterSecond, rain } @@ -293,7 +307,7 @@ final class NativeMatrixRenderer { private var numberIntroSchedule = NumberIntroSchedule() private var numberIntroBlackoutCount = 0 private var numberIntroBlackedColumns = Set() - private var rainStartTime: TimeInterval = 0 // unused; kept for future use + private var rainStartTime: TimeInterval = 0 // wall-clock time when current rain phase began /// Returns the rendered cell content at the requested grid position. subscript(row: Int, column: Int) -> RenderCell { @@ -346,6 +360,7 @@ final class NativeMatrixRenderer { /// Starts the renderer state for a new saver session. func start() { running = true + restartRequested = false lastUpdateTime = Date.timeIntervalSinceReferenceDate pendingSimulationSteps = 0.0 if columns > 0, rows > 0 { @@ -359,6 +374,44 @@ final class NativeMatrixRenderer { running = false } + /// Returns whether a restart has been requested (by `stepRainFrame()` when + /// rain drains), then clears the flag. The View uses this to coordinate + /// the restart across all displays via the ``ScreenSyncCoordinator``. + func consumeRestartRequest() -> Bool { + defer { restartRequested = false } + return restartRequested + } + + /// Returns whether the Neo scene has completed, then clears the flag. + /// The View uses this to coordinate the scene transition across all displays + /// via the ``ScreenSyncCoordinator``. + func consumeNeoSceneDone() -> Bool { + defer { neoSceneDoneRequested = false } + return neoSceneDoneRequested + } + + /// 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``. + func consumeNumberSceneDone() -> Bool { + defer { numberSceneDoneRequested = false } + return numberSceneDoneRequested + } + + /// Advances to the next scene in the sequence after a phase-advance barrier has resolved. + /// The View calls this after coordinating with other displays via the ``ScreenSyncCoordinator``. + func advanceToNextScene() { + switch activeScene { + case .neoMessage: + transitionFromNeoMessage() + case .numberIntro: + beginRainScene() + case .rainForever: + // Rain doesn't advance to another scene; it cycles via restart + break + } + } + /// Resizes the terminal grid and resets scene state to match it. func resize(to size: TerminalSize) { columns = max(size.columns, 1) @@ -443,22 +496,60 @@ final class NativeMatrixRenderer { } } + /// Initializes the rain scene state. + private func beginRainScene(at wallClockTime: TimeInterval = Date.timeIntervalSinceReferenceDate) { + activeScene = .rainForever + rainStartTime = wallClockTime + rainProducing = true + } + /// Advances the continuous rain scene by one simulation step. - private func stepRainFrame() { - if frameIndex % spawnModulo == 0 { - addRandomThread() - } - frameIndex += 1 - now += 1 - - layers[0].stepThreads(now: now, glyphProvider: randomGlyph) - layers[0].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph) - layers[1].stepThreads(now: now, glyphProvider: randomGlyph) - layers[1].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph) - layers[2].stepThreads(now: now, glyphProvider: randomGlyph) + private func stepRainFrame() { + // Check if we need to stop producing new lines (wind-down) + if !configuration.rainRunForever && rainProducing { + let elapsedSeconds = Date.timeIntervalSinceReferenceDate - rainStartTime + let durationSeconds = TimeInterval(configuration.rainDurationSeconds) + if elapsedSeconds >= durationSeconds { + rainProducing = false + } + } + + // Only spawn new lines if rain is actively producing + if rainProducing && frameIndex % spawnModulo == 0 { + addRandomThread() + } + frameIndex += 1 + now += 1 + + // Lines continue falling and stamping regardless; only spawning is gated + layers[0].stepThreads(now: now, glyphProvider: randomGlyph) + layers[0].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph) + + layers[1].stepThreads(now: now, glyphProvider: randomGlyph) + layers[1].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph) + + layers[2].stepThreads(now: now, glyphProvider: randomGlyph) layers[2].resolveLevels(now: now, errorRateModulo: errorRateModulo, glyphProvider: randomGlyph) constructRenderContent() + + // If winding down and all layers are empty, restart the scene sequence + if !rainProducing && !configuration.rainRunForever { + let allLayersEmpty = layers.allSatisfy { layer in + layer.threads.isEmpty && layer.content.allSatisfy { $0.isBlank } + } + if allLayersEmpty && !restartRequested { + if configuration.skipSyncDelay { + // Preview context: self-restart in place + resetLayers() + overrideSceneStartTime = nil + beginSceneSequence() + } else { + // Engine context: hand off restart to View for coordinated timing + restartRequested = true + } + } + } } // MARK: - Number intro scene @@ -617,7 +708,7 @@ final class NativeMatrixRenderer { } private func stepNumberRain(sceneTime: TimeInterval) { - while activeScene == .numberIntro { + while activeScene == .numberIntro && !numberSceneDoneRequested { let expectedSceneTime = numberIntroSchedule.rainStart + Double(numberIntroBlackoutCount) * Self.numberIntroBlackoutInterval if sceneTime >= expectedSceneTime { @@ -626,7 +717,7 @@ final class NativeMatrixRenderer { break } } - guard activeScene == .numberIntro else { return } + guard activeScene == .numberIntro && !numberSceneDoneRequested else { return } scrollNumberRain() self.now += 1 } @@ -724,12 +815,17 @@ final class NativeMatrixRenderer { backgroundLevel: 0, bold: false) numberIntroBlackedColumns.insert(col) } - visibleCountByRow[m] = (colStart..= numberIntroSchedule.blackoutRounds.count { - activeScene = .rainForever - rainStartTime = Date.timeIntervalSinceReferenceDate - } + visibleCountByRow[m] = (colStart..= numberIntroSchedule.blackoutRounds.count { + if configuration.skipSyncDelay { + // Preview context: self-transition immediately + beginRainScene() + } else { + // Engine context: signal completion and let View coordinate barrier + numberSceneDoneRequested = true + } + } } /// Returns a random palette level with power clamped to 0.2–0.6. @@ -770,7 +866,13 @@ final class NativeMatrixRenderer { setRow0(text: state.currentLine ?? "", typedCount: state.visibleCharCount, showCursor: false) markAllRowsDirty() if neoScene.phase == .done { - transitionFromNeoMessage() + if configuration.skipSyncDelay { + // Preview context: self-transition immediately + transitionFromNeoMessage() + } else { + // Engine context: signal completion and let View coordinate barrier + neoSceneDoneRequested = true + } } } @@ -780,8 +882,7 @@ final class NativeMatrixRenderer { activeScene = .numberIntro initNumberIntroScene() } else { - activeScene = .rainForever - rainStartTime = Date.timeIntervalSinceReferenceDate + beginRainScene() stepRainFrame() } } @@ -791,19 +892,21 @@ final class NativeMatrixRenderer { now = 100 frameIndex = 0 - // In preview contexts (System Settings thumbnail, preview host) skip the - // multi-screen sync delay so scenes begin within one second of launch. - // In the screensaver engine the 10-second boundary ensures all displays - // sharing the same activation window use an identical startTime and seed. + // When the ScreenSyncCoordinator provides a shared start time use it + // directly so every display begins at the exact same moment. Otherwise + // fall back to the legacy heuristics: skip the delay in preview contexts, + // or quantise to a 10-second window in ScreenSaverEngine. let nowTime = Date.timeIntervalSinceReferenceDate - if configuration.skipSyncDelay { + if let override = overrideSceneStartTime { + sceneStartTime = override + } else if configuration.skipSyncDelay { sceneStartTime = nowTime + 1.0 } else { let syncWindow: TimeInterval = 10.0 sceneStartTime = floor(nowTime / syncWindow) * syncWindow + syncWindow } - sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) ^ seedOffset - rainRNG = Xorshift64(seed: sceneSeed &+ 3) + sceneSeed = UInt64(bitPattern: Int64(sceneStartTime)) + rainRNG = Xorshift64(seed: sceneSeed &+ 3 &+ seedOffset) guard columns > 0, rows > 0 else { if configuration.neoMessageSceneEnabled { @@ -826,8 +929,8 @@ final class NativeMatrixRenderer { activeScene = .numberIntro initNumberIntroScene() } else { - activeScene = .rainForever - rainStartTime = nowTime + let nowTime = Date.timeIntervalSinceReferenceDate + beginRainScene(at: nowTime) stepRainFrame() } } diff --git a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift new file mode 100644 index 0000000..851ea47 --- /dev/null +++ b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift @@ -0,0 +1,281 @@ +import Foundation + +/// Coordinates multi-screen startup so every screen saver instance begins +/// its scene sequence at the exact same moment. +/// +/// `ScreenSaverEngine` creates one `MatrixScreenSaverView` per attached +/// display. Without coordination, instances that start at different moments +/// may compute different scene start times, producing misaligned scenes. +/// +/// Each view calls ``register(_:expectedCount:)`` during `startAnimation()` +/// and polls ``resolvedStartTime()`` each frame. Once all expected instances +/// have registered — or the ``registrationTimeout`` elapses — every instance +/// receives the same start time and begins simultaneously. +final class ScreenSyncCoordinator { + static let shared = ScreenSyncCoordinator() + + /// Maximum seconds to wait for all instances before starting anyway. + static let registrationTimeout: TimeInterval = 20.0 + + /// Seconds after all instances register (or timeout) before scenes begin. + static let startDelay: TimeInterval = 1.0 + + /// Maximum seconds to wait for a stuck/hidden display during restart before resolving anyway. + static let restartSafetyTimeout: TimeInterval = 60.0 + + private var expectedCount = 0 + private var maxExpectedCount = 0 + private var registeredIDs: Set = [] + private var firstRegistrationTime: TimeInterval = 0 + private var resolvedTime: TimeInterval? + + /// Instances that have requested a rain restart. + private var restartRequestedIDs: Set = [] + /// Resolved time for the next coordinated restart, once all displays are ready. + private var restartResolvedTime: TimeInterval? + /// Snapshot of when the first restart request arrived (for timeout fallback). + private var firstRestartRequestTime: TimeInterval = 0 + + /// Instances that have requested a phase advance (scene transition). + private var phaseAdvanceRequestedIDs: Set = [] + /// Resolved time for the phase advance barrier. + private var phaseAdvanceResolvedTime: TimeInterval? + /// Snapshot of when the first phase advance request arrived (for timeout). + private var firstPhaseAdvanceRequestTime: TimeInterval = 0 + + /// Maximum seconds to wait for all displays during a phase advance before resolving anyway. + static let phaseAdvanceTimeout: TimeInterval = 20.0 + + /// Delay before phase advance proceeds (0s since timing is pre-computed from shared seed). + static let phaseAdvanceDelay: TimeInterval = 0.0 + + /// The number of currently registered instances. + var registeredCount: Int { registeredIDs.count } + + /// The number of expected instances (set on first registration). + var expected: Int { expectedCount } + + /// Registers a screen saver instance and records the expected display count. + /// + /// Call once per instance from `startAnimation()`. On the first call of a + /// session the coordinator snapshots `expectedCount` and starts the timeout + /// clock. Subsequent calls add to the registered set. + /// + /// - Parameters: + /// - id: Unique identity of the registering view (use `ObjectIdentifier`). + /// - expectedCount: Total number of displays (typically `NSScreen.screens.count`). + func register(_ id: ObjectIdentifier, expectedCount: Int) { + if registeredIDs.isEmpty { + self.expectedCount = max(expectedCount, 1) + self.maxExpectedCount = max(expectedCount, 1) + firstRegistrationTime = Date.timeIntervalSinceReferenceDate + resolvedTime = nil + } else { + // Update maxExpectedCount if a larger count is seen (handles delayed OS discovery). + maxExpectedCount = max(maxExpectedCount, expectedCount) + } + registeredIDs.insert(id) + + // Try to resolve startup using live registered count vs max expected. + tryResolveStart() + } + + /// Removes an instance from the coordinator. + /// + /// When the last instance unregisters the coordinator resets, ready for + /// the next screen saver activation. + /// + /// - Parameter id: Unique identity of the unregistering view. + func unregister(_ id: ObjectIdentifier) { + registeredIDs.remove(id) + restartRequestedIDs.remove(id) + phaseAdvanceRequestedIDs.remove(id) + if registeredIDs.isEmpty { + resolvedTime = nil + restartResolvedTime = nil + phaseAdvanceResolvedTime = nil + expectedCount = 0 + maxExpectedCount = 0 + } else { + // If a straggler detaches during startup, remaining displays must be able to resolve + // their startup without waiting for it. Check against max expected. + tryResolveStart() + // If a straggler detaches during restart, remaining displays must be able to resolve. + tryResolveRestart() + // If a straggler detaches during phase advance, remaining displays must be able to resolve. + tryResolvePhaseAdvance() + } + } + + /// Attempts to resolve startup time if all currently-registered displays + /// have registered. + /// + /// Called after each registration and when an instance unregisters, + /// so the coordinator can resolve as soon as all live displays have registered. + private func tryResolveStart() { + guard resolvedTime == nil, !registeredIDs.isEmpty else { return } + if registeredIDs.count >= maxExpectedCount { + resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay + } + } + + /// Attempts to resolve a restart time if all currently-registered displays + /// have requested a restart. + /// + /// This method is called after each restart request and when an instance unregisters, + /// so the coordinator can resolve as soon as all live displays have drained. + private func tryResolveRestart() { + guard restartResolvedTime == nil, !restartRequestedIDs.isEmpty else { return } + if restartRequestedIDs.count >= registeredIDs.count { + restartResolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay + } + } + + /// Returns the shared scene start time once all instances are ready, or + /// after ``registrationTimeout`` elapses. Returns `nil` while still + /// waiting for instances. + func resolvedStartTime() -> TimeInterval? { + if let time = resolvedTime { + return time + } + guard !registeredIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstRegistrationTime + if elapsed >= Self.registrationTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.startDelay + resolvedTime = time + return time + } + return nil + } + + /// Requests a coordinated restart for the given instance. + /// + /// Call when the renderer's rain has fully drained and needs to cycle. + /// The coordinator accumulates restart requests from all displays, then + /// resolves a shared restart time once all have requested or timeout elapses. + /// Use ``resolvedRestartTime()`` to poll for the resolved time each frame, + /// and ``finishRestart(_:)`` once the instance has restarted. + /// + /// - Parameter id: Unique identity of the instance requesting restart. + func requestRestart(_ id: ObjectIdentifier) { + if restartRequestedIDs.isEmpty { + firstRestartRequestTime = Date.timeIntervalSinceReferenceDate + restartResolvedTime = nil + } + restartRequestedIDs.insert(id) + + // Check if all currently-registered displays have now requested a restart. + tryResolveRestart() + } + + /// Returns the shared restart time once all instances have requested + /// a restart, or after ``restartSafetyTimeout`` elapses. Returns `nil` + /// while still waiting. + func resolvedRestartTime() -> TimeInterval? { + if let time = restartResolvedTime { + return time + } + guard !restartRequestedIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstRestartRequestTime + if elapsed >= Self.restartSafetyTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.startDelay + restartResolvedTime = time + return time + } + return nil + } + + /// Marks the given instance as having completed its restart. + /// + /// Call after the instance has restarted its rain sequence. When the last + /// instance calls this, the restart barrier clears and is ready for the + /// next rain cycle. + /// + /// - Parameter id: Unique identity of the instance that restarted. + func finishRestart(_ id: ObjectIdentifier) { + restartRequestedIDs.remove(id) + if restartRequestedIDs.isEmpty { + restartResolvedTime = nil + } + } + + /// Requests a coordinated phase advance (scene transition) for the given instance. + /// + /// Call when a scene completes and needs to transition to the next scene. + /// The coordinator accumulates requests from all displays, then resolves a shared + /// time once all have requested or timeout elapses. + /// Use ``resolvedPhaseAdvanceTime()`` to poll for the resolved time each frame, + /// and ``finishPhaseAdvance(_:)`` once the instance has advanced. + /// + /// - Parameter id: Unique identity of the instance requesting phase advance. + func requestPhaseAdvance(_ id: ObjectIdentifier) { + if phaseAdvanceRequestedIDs.isEmpty { + firstPhaseAdvanceRequestTime = Date.timeIntervalSinceReferenceDate + phaseAdvanceResolvedTime = nil + } + phaseAdvanceRequestedIDs.insert(id) + + // Check if all currently-registered displays have now requested a phase advance. + tryResolvePhaseAdvance() + } + + /// Attempts to resolve phase advance time if all currently-registered displays + /// have requested a phase advance. + /// + /// Called after each phase advance request and when an instance unregisters. + private func tryResolvePhaseAdvance() { + guard phaseAdvanceResolvedTime == nil, !phaseAdvanceRequestedIDs.isEmpty else { return } + if phaseAdvanceRequestedIDs.count >= registeredIDs.count { + phaseAdvanceResolvedTime = Date.timeIntervalSinceReferenceDate + Self.phaseAdvanceDelay + } + } + + /// Returns the shared phase advance time once all instances have requested + /// a phase advance, or after ``phaseAdvanceTimeout`` elapses. Returns `nil` + /// while still waiting. + func resolvedPhaseAdvanceTime() -> TimeInterval? { + if let time = phaseAdvanceResolvedTime { + return time + } + guard !phaseAdvanceRequestedIDs.isEmpty else { return nil } + + let elapsed = Date.timeIntervalSinceReferenceDate - firstPhaseAdvanceRequestTime + if elapsed >= Self.phaseAdvanceTimeout { + let time = Date.timeIntervalSinceReferenceDate + Self.phaseAdvanceDelay + phaseAdvanceResolvedTime = time + return time + } + return nil + } + + /// Marks the given instance as having completed its phase advance. + /// + /// Call after the instance has transitioned to the next scene. When the last + /// instance calls this, the phase advance barrier clears and is ready for the + /// next scene transition. + /// + /// - Parameter id: Unique identity of the instance that advanced. + func finishPhaseAdvance(_ id: ObjectIdentifier) { + phaseAdvanceRequestedIDs.remove(id) + if phaseAdvanceRequestedIDs.isEmpty { + phaseAdvanceResolvedTime = nil + } + } + + /// Resets the coordinator to a clean state (for testing only). + func _resetForTesting() { + expectedCount = 0 + maxExpectedCount = 0 + registeredIDs = [] + firstRegistrationTime = 0 + resolvedTime = nil + restartRequestedIDs = [] + restartResolvedTime = nil + firstRestartRequestTime = 0 + phaseAdvanceRequestedIDs = [] + phaseAdvanceResolvedTime = nil + firstPhaseAdvanceRequestTime = 0 + } +} diff --git a/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift b/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift new file mode 100644 index 0000000..0e159d2 --- /dev/null +++ b/Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift @@ -0,0 +1,249 @@ +import Foundation + +/// Tests for ScreenSyncCoordinator restart barrier resolution logic. +func screenSyncCoordinatorTests() { + // Test 1: All displays requesting → immediate resolution + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: restart not resolved with 2/3 requesting") + + coordinator.requestRestart(id3) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: restart resolved when all 3 request") + + let now = Date.timeIntervalSinceReferenceDate + ok(resolved! > now && resolved! <= now + 2.0, "ScreenSyncCoordinator: startDelay applied to restart") + } + + // Test 2: Straggler unregister → remaining displays resolve + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id3 before unregister") + + coordinator.unregister(id3) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: resolves immediately after straggler unregisters") + ok(coordinator.registeredCount == 2, "ScreenSyncCoordinator: registered count dropped to 2") + } + + // Test 3: finishRestart re-arms the barrier + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: first cycle resolves") + + coordinator.finishRestart(id1) + coordinator.finishRestart(id2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: second cycle resolves (barrier re-armed)") + } + + // Test 4: Partial finish behavior + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + coordinator.requestRestart(id2) + var resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: resolved with both requesting") + + coordinator.finishRestart(id1) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: still resolved while id2 unfinished") + + coordinator.finishRestart(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: clears after all finish") + } + + // Test 5: Unregister during pending restart + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestRestart(id1) + var resolved = coordinator.resolvedRestartTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id2") + + coordinator.unregister(id2) + resolved = coordinator.resolvedRestartTime() + ok(resolved != nil, "ScreenSyncCoordinator: unregister of sole pending requester allows resolution") + } + + // Test 6: Startup barrier fix — delayed registration with higher expected count + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + // First screen registers with low count (OS hasn't discovered all displays yet) + coordinator.register(id1, expectedCount: 2) + var startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup not resolved with only 1/2 registered") + + // Second screen registers with higher count (OS discovers more displays) + coordinator.register(id2, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup not resolved with 2/3 registered even though maxExpected=3") + + // Third screen completes the registration + coordinator.register(id3, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime != nil, "ScreenSyncCoordinator: startup resolves when maxExpected count is reached") + } + + // Test 7: Startup barrier fix — uses maxExpectedCount not first registration count + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + // First screen sees 2 displays, but actually there are 3 (delayed discovery) + coordinator.register(id1, expectedCount: 2) + var startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup waiting (1 < 2)") + + // Second screen discovers all 3 displays + coordinator.register(id2, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime == nil, "ScreenSyncCoordinator: startup waits for maxExpected (2 < 3 after second registration)") + + // Third screen confirms 3 displays + coordinator.register(id3, expectedCount: 3) + startTime = coordinator.resolvedStartTime() + ok(startTime != nil, "ScreenSyncCoordinator: startup resolves when all maxExpected registers (3 >= 3)") + } + + // Test 8: Phase advance barrier — all displays requesting → immediate resolution + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved == nil, "ScreenSyncCoordinator: phase advance not resolved with 2/3 requesting") + + coordinator.requestPhaseAdvance(id3) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: phase advance resolved when all 3 request") + + let now = Date.timeIntervalSinceReferenceDate + ok(resolved! >= now - 0.1 && resolved! <= now + 0.1, "ScreenSyncCoordinator: phaseAdvanceDelay=0 applied (within 0.1s)") + } + + // Test 9: Phase advance barrier — straggler unregister → remaining displays resolve + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject(), obj3 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + let id3 = ObjectIdentifier(obj3) + + coordinator.register(id1, expectedCount: 3) + coordinator.register(id2, expectedCount: 3) + coordinator.register(id3, expectedCount: 3) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved == nil, "ScreenSyncCoordinator: waiting for id3 before unregister") + + coordinator.unregister(id3) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: phase advance resolves immediately after straggler unregisters") + ok(coordinator.registeredCount == 2, "ScreenSyncCoordinator: registered count dropped to 2") + } + + // Test 10: Phase advance barrier — finishPhaseAdvance re-arms the barrier + do { + ScreenSyncCoordinator.shared._resetForTesting() + let coordinator = ScreenSyncCoordinator.shared + let obj1 = NSObject(), obj2 = NSObject() + let id1 = ObjectIdentifier(obj1) + let id2 = ObjectIdentifier(obj2) + + coordinator.register(id1, expectedCount: 2) + coordinator.register(id2, expectedCount: 2) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + var resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: first phase advance cycle resolves") + + coordinator.finishPhaseAdvance(id1) + coordinator.finishPhaseAdvance(id2) + + coordinator.requestPhaseAdvance(id1) + coordinator.requestPhaseAdvance(id2) + resolved = coordinator.resolvedPhaseAdvanceTime() + ok(resolved != nil, "ScreenSyncCoordinator: second phase advance cycle resolves (barrier re-armed)") + } +} diff --git a/Tests/MatrixScreenSaverTests/main.swift b/Tests/MatrixScreenSaverTests/main.swift index 6bf26a9..014d59c 100644 --- a/Tests/MatrixScreenSaverTests/main.swift +++ b/Tests/MatrixScreenSaverTests/main.swift @@ -16,6 +16,7 @@ func ok(_ condition: Bool, _ description: String, file: StaticString = #file, li } xorshift64Tests() +screenSyncCoordinatorTests() nativeMatrixRendererTests() print("1..\(testCount)") diff --git a/build.sh b/build.sh index e6c6e30..d7830b5 100755 --- a/build.sh +++ b/build.sh @@ -38,6 +38,7 @@ swiftc \ "$SOURCE_DIR/NeoMessageScene.swift" \ "$SOURCE_DIR/MatrixRendererLimits.swift" \ "$SOURCE_DIR/NativeMatrixRenderer.swift" \ + "$SOURCE_DIR/ScreenSyncCoordinator.swift" \ "$SOURCE_DIR/MatrixScreenSaverView.swift" cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist" diff --git a/tests.sh b/tests.sh index f725aca..3307231 100755 --- a/tests.sh +++ b/tests.sh @@ -14,8 +14,10 @@ swiftc \ "$SRC/Xorshift64.swift" \ "$SRC/NeoMessageScene.swift" \ "$SRC/MatrixRendererLimits.swift" \ + "$SRC/ScreenSyncCoordinator.swift" \ "$SRC/NativeMatrixRenderer.swift" \ "$TESTS/Xorshift64Tests.swift" \ + "$TESTS/ScreenSyncCoordinatorTests.swift" \ "$TESTS/NativeMatrixRendererTests.swift" \ "$TESTS/main.swift" \ -o "$TEST_BIN"