From dc386ee07163b3ddac8d252e3cbfb8e35bd7d530 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 21 May 2026 16:10:52 +0200 Subject: [PATCH 01/10] fix: source release changelog from development branch (#80) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - branch release from origin/development instead of main - replace --no-merges with --first-parent to avoid duplicates - reorder steps: branch creation → build → changelog → commit - defer VERSION write to the release branch - skip build/changelog/commit on reruns via created output flag --- .github/workflows/release.yml | 63 +++++++++++++++++++++++------------ 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 324fd7a..88a3521 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,8 +86,6 @@ 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" @@ -117,7 +115,42 @@ jobs: echo "branch_exists=false" >> "$GITHUB_OUTPUT" fi + - name: Create or reuse release branch + id: branch + 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 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 + + echo "created=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Branch from development so the release includes all new changes + 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 + + echo "created=true" >> "$GITHUB_OUTPUT" + - name: Validate build + if: steps.branch.outputs.created == 'true' shell: bash run: | set -euo pipefail @@ -125,6 +158,7 @@ jobs: ./build.sh - name: Update CHANGELOG + if: steps.branch.outputs.created == 'true' id: changelog shell: bash run: | @@ -138,7 +172,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 +190,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,30 +210,15 @@ jobs: } > "${tmp}" mv "${tmp}" CHANGELOG.md - - name: Create or reuse release branch + - name: Commit and push release changes + if: steps.branch.outputs.created == 'true' 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" From d561c1fec3b9e12503b9facea6af96c3717058f7 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 21 May 2026 16:19:52 +0200 Subject: [PATCH 02/10] fix: allow release workflow dispatch from development branch (#81) - accept both main and development in the branch guard --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88a3521..fa32b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,13 +43,13 @@ jobs: with: fetch-depth: 0 - - name: Ensure release runs from main + - name: Ensure release runs from main or 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/main" && "${GITHUB_REF}" != "refs/heads/development" ]]; then + echo "Run this workflow from main or development." >&2 exit 1 fi From 46ec988d45f88a136bb44e3b9e6094aaf4025dbc Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 21 May 2026 16:30:22 +0200 Subject: [PATCH 03/10] fix: always regenerate changelog on release reruns (#82) - remove branch reuse logic that skipped changelog regeneration - always recreate release branch from development - use --force-with-lease to update existing remote branch - remove unused branch_exists check from inspect step --- .github/workflows/release.yml | 37 ++++------------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa32b12..7b335a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,13 +91,11 @@ jobs: 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 @@ -109,37 +107,15 @@ 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 or reuse release branch - id: branch + - name: Create release branch 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 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 - - echo "created=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Branch from development so the release includes all new changes + # 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 @@ -147,10 +123,7 @@ jobs: # (e.g. previous release chore commits) git merge origin/main --no-edit - echo "created=true" >> "$GITHUB_OUTPUT" - - name: Validate build - if: steps.branch.outputs.created == 'true' shell: bash run: | set -euo pipefail @@ -158,7 +131,6 @@ jobs: ./build.sh - name: Update CHANGELOG - if: steps.branch.outputs.created == 'true' id: changelog shell: bash run: | @@ -211,7 +183,6 @@ jobs: mv "${tmp}" CHANGELOG.md - name: Commit and push release changes - if: steps.branch.outputs.created == 'true' shell: bash run: | set -euo pipefail @@ -222,7 +193,7 @@ jobs: 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 id: pr From 193607a668cbaa5b205c6311d614f47360341121 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Thu, 21 May 2026 16:40:13 +0200 Subject: [PATCH 04/10] fix: restrict release workflow dispatch to development branch (#83) - only allow workflow_dispatch from development - publish job push trigger on main stays unchanged --- .github/workflows/release.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b335a2..79eccd6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,13 +43,13 @@ jobs: with: fetch-depth: 0 - - name: Ensure release runs from main or development + - name: Ensure release runs from development shell: bash run: | set -euo pipefail - if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != "refs/heads/development" ]]; then - echo "Run this workflow from main or development." >&2 + if [[ "${GITHUB_REF}" != "refs/heads/development" ]]; then + echo "Run this workflow from development." >&2 exit 1 fi From 2925b4988d0445400b5c4355b77ba33cec260c5c Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 22 May 2026 09:06:32 +0200 Subject: [PATCH 05/10] feat: multi-screen sync coordinator (#85) - add ScreenSyncCoordinator to synchronize scene start across displays - defer renderer start until all instances register (20s timeout) - share sceneSeed across displays for identical scripted scenes - keep seedOffset in rainRNG only for per-display rain independence - add overrideSceneStartTime to NativeMatrixRenderer - register/unregister in MatrixScreenSaverView lifecycle Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../MatrixScreenSaverView.swift | 34 +++++++- .../NativeMatrixRenderer.swift | 27 ++++-- .../ScreenSyncCoordinator.swift | 87 +++++++++++++++++++ build.sh | 1 + 4 files changed, 139 insertions(+), 10 deletions(-) create mode 100644 Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift index 61b458f..35132d0 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -40,6 +40,10 @@ 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 + private var windowRect = NSRect.zero private var titlebarRect = NSRect.zero private var terminalRect = NSRect.zero @@ -180,14 +184,27 @@ 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 nativeRenderer.stop() + ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() if wasActive { NSLog("MatrixScreenSaver stopAnimation") @@ -211,6 +228,20 @@ final class MatrixScreenSaverView: ScreenSaverView { return } firstHiddenFrameTime = 0 + + // While waiting for multi-screen sync, 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 { + guard let startTime = ScreenSyncCoordinator.shared.resolvedStartTime() else { + return + } + pendingSync = false + nativeRenderer.overrideSceneStartTime = startTime + nativeRenderer.start() + NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", startTime) + } + super.animateOneFrame() if nativeRenderer.advance() { needsDisplay = true @@ -222,6 +253,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..140bcd3 100644 --- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift +++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift @@ -239,10 +239,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 @@ -791,19 +798,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 { diff --git a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift new file mode 100644 index 0000000..70a7a47 --- /dev/null +++ b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift @@ -0,0 +1,87 @@ +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 + + private var expectedCount = 0 + private var registeredIDs: Set = [] + private var firstRegistrationTime: TimeInterval = 0 + private var resolvedTime: TimeInterval? + + /// 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) + firstRegistrationTime = Date.timeIntervalSinceReferenceDate + resolvedTime = nil + } + registeredIDs.insert(id) + + if registeredIDs.count >= self.expectedCount && resolvedTime == nil { + resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay + } + } + + /// 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) + if registeredIDs.isEmpty { + resolvedTime = nil + expectedCount = 0 + } + } + + /// 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 + } +} 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" From 41abaa7b11f2f0c68b882782a664f56943e0cc58 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 22 May 2026 09:40:37 +0200 Subject: [PATCH 06/10] fix: trigger checks on release branch push (#86) - add release/** to push triggers in tests.yml and commitlint.yml - PRs created by GITHUB_TOKEN don't fire pull_request events - push trigger ensures checks run on the release branch SHA Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/commitlint.yml | 3 +++ .github/workflows/tests.yml | 1 + 2 files changed, 4 insertions(+) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index b23f498..58ee3cb 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,6 +1,9 @@ name: Commitlint on: + push: + branches: + - 'release/**' pull_request: branches: - main diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index daca352..a986890 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,6 +9,7 @@ on: - development - 'feat/**' - 'fix/**' + - 'release/**' pull_request: branches: - development From 5cd65cfe2d98a4c7380919ff327e4457f723e2c7 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 22 May 2026 11:20:35 +0200 Subject: [PATCH 07/10] fix: run required checks inline in release workflow (#87) - add release-tests and release-lint jobs to release.yml - GITHUB_TOKEN pushes don't trigger other workflows - inline jobs use matching check names to satisfy branch protection - revert ineffective release/** push triggers from tests.yml and commitlint.yml Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/commitlint.yml | 3 --- .github/workflows/release.yml | 37 ++++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 1 - 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index 58ee3cb..b23f498 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -1,9 +1,6 @@ name: Commitlint on: - push: - branches: - - 'release/**' pull_request: branches: - main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79eccd6..5d498fd 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 }} @@ -229,6 +231,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/.github/workflows/tests.yml b/.github/workflows/tests.yml index a986890..daca352 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,6 @@ on: - development - 'feat/**' - 'fix/**' - - 'release/**' pull_request: branches: - development From cdd84ff8bc97fe30c9c0a74e538d0cd88473f155 Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Fri, 22 May 2026 13:29:03 +0200 Subject: [PATCH 08/10] fix: recreate release PR instead of reusing (#88) - close existing release PR before creating a new one - reused PRs carry stale check-suite expectations that stay pending - fresh PR picks up check runs from the current workflow run Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/release.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d498fd..fd839c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,7 +197,7 @@ jobs: git commit -m "chore(release): ${version}" -m "- bump VERSION to ${version}" -m "- update CHANGELOG.md" 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: | @@ -213,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 From 1dfc3b26f44e2b985e55977719b1f746c76fa2af Mon Sep 17 00:00:00 2001 From: Patrick Schaper Date: Mon, 8 Jun 2026 17:14:40 +0200 Subject: [PATCH 09/10] fix: exit number rain loop when scene completes (#89) - Add !numberSceneDoneRequested check to while condition in stepNumberRain() - Add !numberSceneDoneRequested check to guard in stepNumberRain() - Prevents infinite loop when scene signals completion via flag - Allows phase-advance barrier to resolve and transition to rain scene --- .../MatrixScreenSaverOptions.swift | 227 +++++++++++----- .../MatrixScreenSaverView.swift | 64 ++++- .../NativeMatrixRenderer.swift | 154 ++++++++--- .../ScreenSyncCoordinator.swift | 200 +++++++++++++- .../ScreenSyncCoordinatorTests.swift | 249 ++++++++++++++++++ Tests/MatrixScreenSaverTests/main.swift | 1 + tests.sh | 2 + 7 files changed, 786 insertions(+), 111 deletions(-) create mode 100644 Tests/MatrixScreenSaverTests/ScreenSyncCoordinatorTests.swift 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 35132d0..ad20a59 100644 --- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift +++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift @@ -44,6 +44,14 @@ final class MatrixScreenSaverView: ScreenSaverView { /// ``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 @@ -203,6 +211,8 @@ final class MatrixScreenSaverView: ScreenSaverView { let wasActive = animationActive animationActive = false pendingSync = false + pendingRestart = false + pendingSceneAdvance = false nativeRenderer.stop() ScreenSyncCoordinator.shared.unregister(ObjectIdentifier(self)) releaseRenderingResources() @@ -229,23 +239,65 @@ final class MatrixScreenSaverView: ScreenSaverView { } firstHiddenFrameTime = 0 - // While waiting for multi-screen sync, poll the coordinator each - // frame. Once a shared start time is available, apply it and start - // the renderer so all displays begin simultaneously. + // 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 { - guard let startTime = ScreenSyncCoordinator.shared.resolvedStartTime() else { + let startTime: TimeInterval? + if pendingRestart { + startTime = ScreenSyncCoordinator.shared.resolvedRestartTime() + } else { + startTime = ScreenSyncCoordinator.shared.resolvedStartTime() + } + guard let time = startTime else { return } pendingSync = false - nativeRenderer.overrideSceneStartTime = startTime + 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() - NSLog("MatrixScreenSaver sync resolved — starting renderer (startTime=%.2f)", startTime) + } + + // 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. diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift index 140bcd3..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 @@ -273,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 } @@ -300,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 { @@ -353,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 { @@ -366,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) @@ -450,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 @@ -624,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 { @@ -633,7 +717,7 @@ final class NativeMatrixRenderer { break } } - guard activeScene == .numberIntro else { return } + guard activeScene == .numberIntro && !numberSceneDoneRequested else { return } scrollNumberRain() self.now += 1 } @@ -731,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. @@ -777,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 + } } } @@ -787,8 +882,7 @@ final class NativeMatrixRenderer { activeScene = .numberIntro initNumberIntroScene() } else { - activeScene = .rainForever - rainStartTime = Date.timeIntervalSinceReferenceDate + beginRainScene() stepRainFrame() } } @@ -835,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 index 70a7a47..851ea47 100644 --- a/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift +++ b/Sources/MatrixScreenSaver/ScreenSyncCoordinator.swift @@ -20,11 +20,35 @@ final class ScreenSyncCoordinator { /// 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 } @@ -43,14 +67,17 @@ final class ScreenSyncCoordinator { 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) - if registeredIDs.count >= self.expectedCount && resolvedTime == nil { - resolvedTime = Date.timeIntervalSinceReferenceDate + Self.startDelay - } + // Try to resolve startup using live registered count vs max expected. + tryResolveStart() } /// Removes an instance from the coordinator. @@ -61,9 +88,46 @@ final class ScreenSyncCoordinator { /// - 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 } } @@ -84,4 +148,134 @@ final class ScreenSyncCoordinator { } 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/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" From 035ec09fa393ea402521444aca6b9e2f8cc57970 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 05:37:29 +0000 Subject: [PATCH 10/10] chore(release): 0.1.2 - bump VERSION to 0.1.2 - update CHANGELOG.md --- CHANGELOG.md | 23 +++++++++++++++++++++++ VERSION | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81c384d..ab895a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.2] - 2026-06-09 + +### Features + +- multi-screen sync coordinator (#85) +- add two darker brightness steps to rain palette (#71) +- group options sheet into sections (#69) + +### Bug Fixes + +- exit number rain loop when scene completes (#89) +- recreate release PR instead of reusing (#88) +- run required checks inline in release workflow (#87) +- trigger checks on release branch push (#86) +- restrict release workflow dispatch to development branch (#83) +- always regenerate changelog on release reruns (#82) +- allow release workflow dispatch from development branch (#81) +- source release changelog from development branch (#80) +- derive current version from latest git tag in release workflow (#74) +- auto-merge back-merge PR and sync VERSION after release (#73) +- apply typing speed to all intro scenes (#70) + ## [0.1.1] - 2026-05-20 ### Features diff --git a/VERSION b/VERSION index 17e51c3..d917d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1 +0.1.2