diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ef7a364..601d3f3 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -134,7 +134,7 @@ jobs:
./build.sh
./build-wallpaper.sh
codesign --verify --deep --strict "build/MatrixScreenSaver.saver"
- codesign --verify --deep --strict "build/MatrixWallpaper.app"
+ codesign --verify --deep --strict "build/Matrix Wallpaper.app"
- name: Update CHANGELOG
id: changelog
@@ -456,9 +456,9 @@ jobs:
./build.sh
./build-wallpaper.sh
bash Scripts/sign-bundle.sh "build/MatrixScreenSaver.saver"
- bash Scripts/sign-bundle.sh "build/MatrixWallpaper.app"
+ bash Scripts/sign-bundle.sh "build/Matrix Wallpaper.app"
codesign --verify --deep --strict "build/MatrixScreenSaver.saver"
- codesign --verify --deep --strict "build/MatrixWallpaper.app"
+ codesign --verify --deep --strict "build/Matrix Wallpaper.app"
- name: Notarize and staple both bundles
if: steps.release.outputs.exists != 'true'
@@ -491,7 +491,7 @@ jobs:
rm -rf "${release_dir}"
mkdir -p "${release_dir}"
ditto "build/MatrixScreenSaver.saver" "${release_dir}/MatrixScreenSaver.saver"
- ditto "build/MatrixWallpaper.app" "${release_dir}/MatrixWallpaper.app"
+ ditto "build/Matrix Wallpaper.app" "${release_dir}/Matrix Wallpaper.app"
echo "dir=${release_dir}" >> "$GITHUB_OUTPUT"
diff --git a/AGENTS.md b/AGENTS.md
index c41935c..d9abccd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,6 +140,7 @@ class MatrixColumn {
## Git commits
- Follow Conventional Commits, for example: `feat: ...`, `fix: ...`, `docs: ...`, `refactor: ...`, `test: ...`
+- Classify version-number bumps as `chore:`, not `fix:`, in commit messages and PR titles.
- Use a short title line.
- Add a few very concise bullet points for the meaningful changes.
@@ -188,6 +189,8 @@ feat: add character size options
## Releases
+- Keep changelog entries and release notes focused on user-facing changes; omit code-signing changes and details.
+- Use Semantic Versioning (`MAJOR.MINOR.PATCH`): major for breaking changes, minor for backward-compatible features, patch for backward-compatible fixes. Select the corresponding bump in the release workflow; `VERSION` remains the source of truth.
- Trigger releases by running the **Release** GitHub Actions workflow (`workflow_dispatch`) — never manually.
- The workflow bumps the version, updates `CHANGELOG.md`, and opens a `release/*` PR targeting `main`.
- Merging that `release/*` PR into `main` triggers the publish job, which creates the actual GitHub release with a zipped `.saver` bundle attached.
diff --git a/Homebrew/Casks/matrix-screen-saver.rb b/Homebrew/Casks/matrix-screen-saver.rb
index 6d6766a..7da1eb2 100644
--- a/Homebrew/Casks/matrix-screen-saver.rb
+++ b/Homebrew/Casks/matrix-screen-saver.rb
@@ -10,7 +10,7 @@
depends_on arch: :arm64
depends_on macos: :sequoia
- app "MatrixWallpaper.app"
+ app "MatrixWallpaper.app", target: "Matrix Wallpaper.app"
screen_saver "MatrixScreenSaver.saver"
preflight_steps do
@@ -24,13 +24,13 @@
writable_paths: [{ path: "MatrixScreenSaver.saver", base: :screen_saverdir }]
end
- if_path_exists "MatrixWallpaper.app", base: :appdir do
+ if_path_exists "Matrix Wallpaper.app", base: :appdir do
run "/bin/echo",
- args: ["Existing app at {{appdir}}/MatrixWallpaper.app. " \
+ args: ["Existing app at {{appdir}}/Matrix Wallpaper.app. " \
"To replace it while retaining options, run: " \
"brew install --cask --force patrickschaper/tap/matrix-screen-saver"],
print_stdout: true,
- writable_paths: [{ path: "MatrixWallpaper.app", base: :appdir }]
+ writable_paths: [{ path: "Matrix Wallpaper.app", base: :appdir }]
end
run "/bin/echo",
@@ -53,7 +53,7 @@
args: ["Open System Settings > Wallpaper > Screen Saver and select MatrixScreenSaver."],
print_stdout: true
run "/bin/echo",
- args: ["Open {{appdir}}/MatrixWallpaper.app to start Matrix Wallpaper. " \
+ args: ["Open {{appdir}}/Matrix Wallpaper.app to start Matrix Wallpaper. " \
"Launch at Login is optional in its Matrix menu."],
print_stdout: true
end
diff --git a/Homebrew/README.md b/Homebrew/README.md
index 8b54b54..0ace6dd 100644
--- a/Homebrew/README.md
+++ b/Homebrew/README.md
@@ -5,9 +5,13 @@ The public tap is [patrickschaper/homebrew-tap](https://github.com/patrickschape
that repository. Users add it with `brew tap patrickschaper/tap`.
The next release payload uses Homebrew's native `screen_saver` and `app` artifacts,
-not the local installers, to manage `MatrixScreenSaver.saver` and `MatrixWallpaper.app`
+not the local installers, to manage `MatrixScreenSaver.saver` and `Matrix Wallpaper.app`
together. The live tap gains this behavior only after a new dual-bundle release and
-tap update; immutable older releases remain saver-only.
+tap update; immutable saver-only releases remain saver-only.
+The template retains the pinned archive's `MatrixWallpaper.app` source and uses
+Homebrew's `target:` to install it as `Matrix Wallpaper.app`. The release renderer
+selects the source name from each validated archive, so old dual-bundle downloads
+and future renamed bundles both install under the new name.
It retains the `dev.patsch.MatrixScreenSaver` preferences domain and has no zap
or preference-cleanup hook. Its published binary requires Apple Silicon (ARM64)
and macOS 15 (Sequoia) or newer.
@@ -24,7 +28,7 @@ brew style "$PWD/Homebrew/Casks/matrix-screen-saver.rb"
```bash
brew trust --cask patrickschaper/tap/matrix-screen-saver
-brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app
+brew install --cask patrickschaper/tap/matrix-screen-saver && open "/Applications/Matrix Wallpaper.app"
```
The command launches the wallpaper app after installation succeeds, using Homebrew's
@@ -34,7 +38,7 @@ The fully qualified install trusts this cask, not the entire tap. Explicit
`brew trust --cask` also permits subsequent installs using the short name.
**Security:** preflight removes quarantine recursively only from the
-checksum-verified staged `MatrixScreenSaver.saver` and `MatrixWallpaper.app`, before
+checksum-verified staged `MatrixScreenSaver.saver` and wallpaper app, before
Homebrew moves them. This bypasses Gatekeeper quarantine checks; it does not make either bundle notarized
or Apple-approved. Install only if you trust the release publisher. The cask
prints this disclosure before invoking `/usr/bin/xattr`; failure aborts installation.
@@ -43,7 +47,7 @@ After installation, open **System Settings > Wallpaper > Screen Saver** and
select **MatrixScreenSaver** manually. Modern Homebrew's sandbox blocks opening
Settings, so automatic opening was intentionally removed. The cask never selects
the saver, launches the wallpaper on a fresh install, enables login startup, or opens
-a browser. The command above launches **MatrixWallpaper** from your shell after Homebrew
+a browser. The command above launches **Matrix Wallpaper** from your shell after Homebrew
finishes; use its single **Launch at Login** checkbox if wanted.
Homebrew may reopen an already-running app following an upgrade. Successful install,
upgrade, and reinstall print:
@@ -79,6 +83,11 @@ Upgrades and reinstalls remain native Homebrew operations. An upgrade may print
the existing-target guidance because Homebrew can leave an empty target directory
during replacement; no prior-receipt snapshot or initial-install-only behavior
is needed. Custom destinations are honored, and other saver copies are not removed.
+When upgrading a managed `MatrixWallpaper.app`, Homebrew uses its prior receipt
+to remove the old filename and installs `Matrix Wallpaper.app`. A conflict at the
+new path aborts the upgrade and restores the previous managed bundles. Unmanaged
+old-name copies are not removed; use the local installer to migrate a copy in
+`~/Applications`, or remove that copy yourself after confirming the new app works.
The native app and screen-saver artifacts are both installed and removed by the
same cask. Uninstall requests that `dev.patsch.MatrixWallpaper` quit and asks
Homebrew to remove its login items. Homebrew skips login-item cleanup when an
@@ -143,10 +152,10 @@ The Release publish job invokes the script with explicit `--publish` opt-in,
after validating the published asset and uploading the handoff. It writes only
`Casks/matrix-screen-saver.rb` on the tap's default branch; no tap PR or merge is
required. This replaces the previous optional, human-merged tap PR behavior.
-Only version, SHA-256, and minimum macOS metadata may differ from the source
-template. For the first dual-bundle release, the exact frozen saver-only predecessor
-is also accepted for a strictly newer version, using its normalized structure hash.
-This one reviewed transition does not allow arbitrary structural edits. Unrelated
+Only version, SHA-256, minimum macOS metadata, and the validated old/new archive
+source name may differ from the source template. The exact frozen saver-only and
+old-name dual-bundle predecessors are also accepted for a strictly newer version,
+using their normalized structure hashes. These reviewed transitions do not allow arbitrary structural edits. Unrelated
cask edits or a missing cask fail closed for manual review.
Identical reruns make no commit, older versions are skipped, and conflicting
same-version metadata is rejected. Each write includes the current file SHA;
@@ -203,7 +212,8 @@ A separate
sentinel outside the artifact destination verifies preference retention; it does
not access the actual defaults domain. HOME alone is not used as isolation.
-Native integration covers saver-only migration, dual install, conflict failure, forced migration, upgrade,
+Native integration covers saver-only migration, old-name app migration and rollback,
+old/new archive names, dual install, conflict failure, forced migration, upgrade,
reinstall, uninstall, quarantine removal, broken-symlink conflict/force semantics,
and developer-mode acceptance without legacy warnings on definition load or
uninstall. Run workflow lint (for example `actionlint`) separately; never conceal
diff --git a/README.md b/README.md
index f8fb2d6..463900e 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,10 @@ The screen saver also comes with an app replacing your wallpaper with the animat
```bash
brew trust --cask patrickschaper/tap/matrix-screen-saver
-brew install --cask patrickschaper/tap/matrix-screen-saver && open /Applications/MatrixWallpaper.app
+brew install --cask patrickschaper/tap/matrix-screen-saver && open "/Applications/Matrix Wallpaper.app"
```
-- Wallpaper: open **MatrixWallpaper** in Applications. options available through menubar icon.
+- Wallpaper: open **Matrix Wallpaper** in Applications. options available through menubar icon.
- Screen saver options: **System Settings → Wallpaper → Screen Saver → MatrixScreenSaver → Options…**.
## Buy me a coffee
@@ -40,6 +40,7 @@ The saver exposes a native **Options…** sheet. The values are stored with `Scr
| Frame rate | Set the frame rate per second. A positive number less than or equal to 1000. | 25 |
| Error rate | Set the factor for the rate of character changes. A non-negative number. | 1.0 |
| Characters | Restrict rain glyphs to a custom set (e.g. ATGC). Leave empty for the default. | *(empty)* |
+| Font | Pick one installed font for the rain glyphs. Intro text stays in the system font. See [Matrix fonts](#matrix-fonts). | *(system font)* |
| Neo message scene | Show the Neo message intro before the main scene. | On |
| Typing speed | Typing speed multiplier for Neo messages, custom command entry and the trace program. A positive number; pauses keep their natural duration. Output is not typed. | 1.0 |
| Name | Name used in the intro message ("Wake up, {name}..."). Only applies when Neo message lines is empty. | *(empty — uses "Neo")* |
@@ -56,6 +57,14 @@ including while the screen is locked. Command text and output are not logged. Li
until exit and retain at most 64 KiB of combined output; stopping or restarting playback
cancels its command. The sandboxed screen saver may not have the same access as the wallpaper app.
+### Matrix fonts
+
+The **Glyph set** radio buttons in **Rain** select **Default**, **Classic**, or **Resurrections**. Classic and Resurrections use their original bundled fonts and glyph artwork. Default keeps the existing configurable rain. Switching presets preserves your custom font and character settings; these controls are editable when Default is selected.
+
+The **Font** option selects one installed font family, including hidden fonts discovered in the standard system and user font folders. Choosing another font replaces the selection; Reset restores the system font. Existing multi-font settings retain only the first font. Default and custom characters missing from the selected font use macOS font fallback when glyph images are cached, with no extra font lookup during playback. Install any additional fonts yourself.
+
+The bundled [Matrix Code](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Code.ttf) and [Matrix Resurrected](https://github.com/Rezmason/matrix/raw/master/assets/Matrix-Resurrected.ttf) fonts are published in [Rezmason/matrix](https://github.com/Rezmason/matrix) (MIT, © Rezmason 2018), which recreate the glyphs from the films. Thanks to Rezmason for making them available. See [font provenance](Resources/Fonts/README.md) and the [included license](Resources/Fonts/LICENSE.txt).
+
### Wallpaper menubar menu

@@ -67,7 +76,7 @@ cancels its command. The sandboxed screen saver may not have the same access as
- Native Swift/AppKit screen saver bundle
- Edge-to-edge full-screen saver output, with terminal-style chrome kept in the preview host
- In-process renderer for `rain-forever` with an optional upstream `number` intro
-- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Neo message scene/speed/lines, custom shell commands/playback, and Scan lines
+- Native **Options…** sheet for Number scene, Twinkle, Diffuse, Character size, Rain density, Frame rate, Error rate, Characters, Fonts, Neo message scene/speed/lines, custom shell commands/playback, and Scan lines
- Preview app for fast iteration without reinstalling
### Repository layout
@@ -99,7 +108,7 @@ These create the screen saver bundle and wallpaper app:
```text
build/MatrixScreenSaver.saver
-build/MatrixWallpaper.app
+build/Matrix Wallpaper.app
```
Both build scripts read the current version from `./VERSION` and write it into their bundle metadata.
@@ -136,7 +145,7 @@ This rebuilds the bundle, compiles the preview host, and opens the saver in a no
To launch the built wallpaper app without installing:
```bash
-open build/MatrixWallpaper.app
+open "build/Matrix Wallpaper.app"
```
### Install
@@ -167,11 +176,15 @@ To install and launch the wallpaper app in your user Applications folder:
```bash
./install-wallpaper.sh
-open ~/Applications/MatrixWallpaper.app
+open "$HOME/Applications/Matrix Wallpaper.app"
```
The installer builds and verifies the app, gracefully quits running copies before
replacement, and retains preferences. It does not launch the app or enable login startup.
+It migrates a signed `MatrixWallpaper.app` with the matching bundle ID to
+`Matrix Wallpaper.app`, restoring the previous installation if replacement fails.
+Unrelated bundles and symlinks at the old path are left alone. The executable remains
+`MatrixWallpaper` and the bundle ID remains `dev.patsch.MatrixWallpaper`.
When MatrixScreenSaver is installed in `~/Library/Screen Savers` or `/Library/Screen Savers`,
the wallpaper menu shows **Sync with Screen Saver**. It is checked by default: the saver
@@ -180,8 +193,13 @@ The choice survives app restarts and saver reinstallation; opening the menu refr
installation detection. This does not change visual options or synchronization between monitors.
While Matrix Wallpaper runs, it saves each display's desktop image and display options,
-then sets the underlying wallpaper to plain black. **Stop** and **Quit Matrix Wallpaper**
-restore those settings. Temporary pauses and animation restarts keep the original backup.
+then sets the underlying wallpaper to plain black. **Stop** and **Pause/Resume** share
+one native menu row. **Pause** freezes the visible frame; **Resume** continues the scene.
+Pausing is session-only and does not persist across launches. With **Sync with Screen Saver**
+enabled, the saver temporarily advances the shared scene even while the wallpaper is paused.
+When the saver exits, the wallpaper pauses again at the updated scene. **Stop** and
+**Quit Matrix Wallpaper** restore the original wallpaper settings. Temporary pauses
+and animation restarts keep the original backup.
If you choose a different wallpaper while Matrix runs, that newer choice is preserved.
Backups are stored in `~/Library/Application Support/MatrixWallpaper/DesktopWallpaper/`.
diff --git a/Resources/Fonts/LICENSE.txt b/Resources/Fonts/LICENSE.txt
new file mode 100644
index 0000000..8ed7dd3
--- /dev/null
+++ b/Resources/Fonts/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Rezmason
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/Resources/Fonts/Matrix-Code.ttf b/Resources/Fonts/Matrix-Code.ttf
new file mode 100644
index 0000000..95a3a5e
Binary files /dev/null and b/Resources/Fonts/Matrix-Code.ttf differ
diff --git a/Resources/Fonts/Matrix-Resurrected.ttf b/Resources/Fonts/Matrix-Resurrected.ttf
new file mode 100644
index 0000000..58dad20
Binary files /dev/null and b/Resources/Fonts/Matrix-Resurrected.ttf differ
diff --git a/Resources/Fonts/README.md b/Resources/Fonts/README.md
new file mode 100644
index 0000000..d169ca0
--- /dev/null
+++ b/Resources/Fonts/README.md
@@ -0,0 +1,116 @@
+# Rezmason Matrix fonts
+
+Original, unmodified TrueType fonts from [Rezmason/matrix](https://github.com/Rezmason/matrix),
+pinned to commit `5ba90490453ceceb6812d6b1bc658a99a92411d0`.
+Copyright (c) 2018 Rezmason. The upstream MIT license is reproduced verbatim in
+`LICENSE.txt`; distribute it with the fonts.
+
+Upstream paths at that commit:
+
+- `assets/Matrix-Code.ttf` → `Matrix-Code.ttf`
+- `assets/Matrix-Resurrected.ttf` → `Matrix-Resurrected.ttf`
+- `LICENSE` → `LICENSE.txt`
+
+Download base:
+
+SHA-256 checksums:
+
+```text
+5dff3023c91291d28dffc32fd74f5edbbca1969f25077d0499b1f000cabdec5b Matrix-Code.ttf
+cad5cb197237f3eeed8d5d251df9c058caeb8179dc97494bb19447d2cf3ca4fc Matrix-Resurrected.ttf
+65333257cebb87e8af86201a65f0e550d65b3cf5a468bfee2a69c9d2b5d21123 LICENSE.txt
+```
+
+## Verified character mappings
+
+These mappings were inspected directly from the TTF `cmap`, `name`, and `glyf`
+tables with FontTools. All Unicode cmap subtables agree. Family, full, and
+PostScript names are respectively `Matrix-Code` and `Matrix-Resurrected`;
+both use the `Regular` subfamily.
+
+**Classic TTF repertoire:** 57 nonblank mapped glyphs. Their Unicode code points (hexadecimal)
+are the following explicit set, not a contiguous range:
+
+```text
+0022 002A 002B 0030 0031 0032 0033 0034 0035 0037 0038 0039
+003A 003C 003E 007A 007C 00A6 00A9 254C 25AA 30A2 30A6 30A8
+30AA 30AB 30AD 30B1 30B3 30B5 30B7 30B9 30BB 30BD 30BF 30C4
+30C6 30CA 30CB 30CC 30CD 30CF 30D2 30DB 30DE 30DF 30E0 30E1
+30E2 30E4 30E8 30E9 30EA 30EF 30FC A78A E937
+```
+
+**Resurrections:** the TTF contains **138**, not 135, nonblank mapped glyphs:
+the inclusive private-use range **U+E900–U+E989**. Every character maps to a
+different glyph ID with nonzero-width and nonzero-height outline bounds.
+Upstream `js/config.js` specifies 135 active slots for its separate MSDF texture.
+Direct atlas comparison confirms those slots match **U+E900–U+E986**, in order.
+U+E987, U+E988, and U+E989 contain artwork matching inactive atlas slots
+153, 154, and 155; exclude them when reproducing the website's active set.
+Do not infer the TTF repertoire or atlas ordering from upstream's 57-entry
+`glyph order.txt`.
+
+Both fonts additionally map U+0000, U+0001, and U+0020 to separate glyph IDs
+with identical degenerate outlines: three points at (0, 0). Those are blank,
+despite their nonzero contour count. Exclude the controls; retain U+0020 only
+for Classic's intentional blank atlas slot, described below.
+Space has advance width 512; the two control characters have zero advance.
+There are no shared-glyph-ID aliases in the Unicode cmap and no other exactly
+identical coordinate/contour/flag outlines. The Classic font also has unmapped,
+empty `.notdef` and `nonmarkingreturn` glyphs; Resurrections has an empty
+`.notdef` glyph. Glyph counts therefore differ from drawable character counts.
+
+## Website atlas equivalence
+
+Compared the pinned upstream `assets/matrixcode_msdf.png` (512×512, 8×8 cells)
+and `assets/resurrections_msdf.png` (832×768, 13×12 cells) against Pillow/FreeType
+renders of every Unicode-mapped character in the corresponding TTF. Atlas
+indices below are zero-based, row-major from the top left; each cell is 64×64.
+The website uses the first 57 and 135 slots respectively.
+
+Extracted atlas masks with `median(R, G, B) > 127`, cropped each mask to its
+ink bounds, resized to fit a 96×96 canvas **preserving aspect ratio**, and
+compared intersection-over-union against similarly normalized TTF masks
+rendered at 512 pixels. Every nonblank active slot had a unique best match.
+Lowest winning IoU: Classic 0.8673, Resurrections 0.8524. Smallest winning
+margin over the runner-up: Classic 0.1420, Resurrections 0.0808. Normalizing
+width and height independently is unsuitable: it confuses squares with bars.
+
+**Classic website sequence (57 slots):**
+
+```text
+30DB 30A2 0033 30A6 blank 30BB 00A6 003A
+0022 E937 30DF 30E9 30EA 254C 30C4 30C6
+30CB 30CF 30BD 25AA 30FC 003C 003E 0030
+007C 002B 002A 30B3 30B7 30DE 30E0 30E1
+30E2 30A8 30E4 30AD 30AA 30AB 0037 30B1
+30B5 30B9 007A 0031 0035 0032 30E8 30BF
+30EF 0034 30CD 30CC 30CA 0039 0038 30D2
+0030
+```
+
+- Slot 4 is entirely black in the original RGB texture: a genuine blank slot.
+ Represent it explicitly as blank (or U+0020 in a fixed-cell renderer).
+- Slots 23 and 56 have identical thresholded masks and both match U+0030.
+ Preserve both entries to reproduce the website's weighting.
+- Thus the 57 slots comprise **55 distinct visible glyphs**, one duplicate
+ zero, and one blank. The visible set is the Classic TTF repertoire above
+ **minus U+00A9 and U+A78A**. U+A78A matches inactive atlas slot 57;
+ U+00A9 is absent from the active atlas set.
+
+**Resurrections website sequence:** exactly **U+E900–U+E986 inclusive**,
+135 distinct visible glyphs, with slot `i` matching `U+E900 + i` for all
+`0 <= i < 135`. All matches were checked, not inferred from the endpoints.
+No active blank slot or repeated character mapping. Slots 135–152 are blank;
+slots 153–155 match the three excluded TTF characters U+E987–U+E989.
+
+Atlas SHA-256 checksums (investigation inputs, not bundled assets):
+
+```text
+1fd4ed52f8e9cc4af6eaad69d28cd289bf6486f15f00c952548ff0533d6a83ab matrixcode_msdf.png
+f2bfe884b08cacf6c8be09573442d51c5dc5e97bbd8f86bcd86177d4e4b14c79 resurrections_msdf.png
+```
+
+Validation: downloaded font/license files were compared byte-for-byte with the pinned raw
+upstream files, SHA-256 hashed, and parsed with FontTools checksum checking.
+Native CoreText rendering and packaged-bundle loading are separate integration
+checks and were not performed as part of this asset import.
diff --git a/Scripts/notarize.sh b/Scripts/notarize.sh
index 30b4fb7..a633fed 100644
--- a/Scripts/notarize.sh
+++ b/Scripts/notarize.sh
@@ -6,7 +6,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
: "${NOTARY_KEYCHAIN_PROFILE:?Set NOTARY_KEYCHAIN_PROFILE to your notarytool credential profile.}"
-BUNDLES=("$ROOT_DIR/build/MatrixScreenSaver.saver" "$ROOT_DIR/build/MatrixWallpaper.app")
+BUNDLES=("$ROOT_DIR/build/MatrixScreenSaver.saver" "$ROOT_DIR/build/Matrix Wallpaper.app")
for bundle in "${BUNDLES[@]}"; do
codesign --verify --deep --strict "$bundle"
diff --git a/Scripts/render-homebrew-release.py b/Scripts/render-homebrew-release.py
index 3a55482..158ad7c 100644
--- a/Scripts/render-homebrew-release.py
+++ b/Scripts/render-homebrew-release.py
@@ -69,18 +69,24 @@ def render(archive_path, version, url, digest, provenance, output):
names = [entry.filename for entry in entries]
if len(names) != len(set(names)) or sum(e.file_size for e in entries) > 100_000_000:
raise ValueError("Duplicate or oversized archive entries")
+ # Published archives are immutable; accept either filename, but never both.
+ app_roots = {PurePosixPath(name).parts[0] for name in names if PurePosixPath(name).parts} & {
+ "MatrixWallpaper.app", "Matrix Wallpaper.app"}
+ if len(app_roots) != 1:
+ raise ValueError("Expected exactly one wallpaper app bundle")
+ app_root = app_roots.pop()
for entry in entries:
path = PurePosixPath(entry.filename)
if path.is_absolute() or ".." in path.parts or "\\" in entry.filename:
raise ValueError("Unsafe archive path")
- if not path.parts or path.parts[0] not in ("MatrixScreenSaver.saver", "MatrixWallpaper.app", "__MACOSX"):
+ if not path.parts or path.parts[0] not in ("MatrixScreenSaver.saver", app_root, "__MACOSX"):
raise ValueError("Unexpected release archive layout")
if (entry.external_attr >> 16) & 0o170000 == 0o120000:
raise ValueError("Symlinks are not supported in release archives")
minimum = (0, 0, 0)
identifiers = []
- for name, extension, kind in [("MatrixScreenSaver", "saver", 8), ("MatrixWallpaper", "app", 2)]:
- root = f"{name}.{extension}/Contents"
+ for name, bundle, kind in [("MatrixScreenSaver", "MatrixScreenSaver.saver", 8), ("MatrixWallpaper", app_root, 2)]:
+ root = f"{bundle}/Contents"
info = plistlib.loads(archive.read(f"{root}/Info.plist"))
if (info.get("CFBundleIdentifier") != f"dev.patsch.{name}"
or info.get("CFBundleShortVersionString") != version
@@ -96,7 +102,7 @@ def render(archive_path, version, url, digest, provenance, output):
floor, symbol = "26.0", "tahoe"
else:
raise ValueError("Unsupported macOS floor: update compatibility mapping before release")
- cask = TEMPLATE.read_text()
+ cask = TEMPLATE.read_text().replace("MatrixWallpaper.app", app_root)
cask, versions = re.subn(r'^ version "[^"]+"$', f' version "{version}"', cask, flags=re.M)
cask, hashes = re.subn(r'^ sha256 "[0-9a-f]{64}"$', f' sha256 "{sha256}"', cask, flags=re.M)
cask, floors = re.subn(r'^ depends_on macos: :\w+$', f' depends_on macos: :{symbol}', cask, flags=re.M)
diff --git a/Scripts/update-homebrew-tap.py b/Scripts/update-homebrew-tap.py
index fcf7285..717fcd3 100644
--- a/Scripts/update-homebrew-tap.py
+++ b/Scripts/update-homebrew-tap.py
@@ -12,7 +12,11 @@
import urllib.request
TEMPLATE = Path(__file__).resolve().parents[1] / "Homebrew/Casks/matrix-screen-saver.rb"
-LEGACY_STRUCTURE_SHA256 = "5887ff0f5de0edcb895128458af2b519c4243ebe67b6de3a78374c1934c24e59"
+# Reviewed saver-only and old-name dual-bundle predecessors; arbitrary edits still fail closed.
+LEGACY_STRUCTURE_SHA256 = {
+ "5887ff0f5de0edcb895128458af2b519c4243ebe67b6de3a78374c1934c24e59",
+ "85c52f3fa521d0f2d269ebdae5c56f634b715744d0b261f9ac289ddaa47cf047",
+}
def cask_metadata(content):
@@ -57,7 +61,10 @@ def update(api, target, version, content):
if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)", version):
raise ValueError("Invalid release version")
metadata, structure = cask_metadata(content)
- if metadata[0] != version or structure != cask_metadata(TEMPLATE.read_text())[1]:
+ template = TEMPLATE.read_text()
+ structures = {cask_metadata(template)[1],
+ cask_metadata(template.replace("MatrixWallpaper.app", "Matrix Wallpaper.app"))[1]}
+ if metadata[0] != version or structure not in structures:
raise ValueError("Generated cask differs from the release template or version")
repo = f"repos/{target}"
default = api.request("GET", repo)["default_branch"]
@@ -67,8 +74,8 @@ def update(api, target, version, content):
raise ValueError("Tap cask is missing or not a regular file; inspect it before publication")
current = base64.b64decode(existing["content"]).decode()
previous, previous_structure = cask_metadata(current)
- if (previous_structure != structure
- and hashlib.sha256(previous_structure.encode()).hexdigest() != LEGACY_STRUCTURE_SHA256):
+ if (previous_structure not in structures
+ and hashlib.sha256(previous_structure.encode()).hexdigest() not in LEGACY_STRUCTURE_SHA256):
raise ValueError("Tap cask has unrelated edits; review the manual handoff (no overwrite)")
if tuple(map(int, previous[0].split("."))) > tuple(map(int, version.split("."))):
return f"Tap already has newer version {previous[0]}; skipped {version}"
diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift
index 72bbce8..e4b2946 100644
--- a/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift
+++ b/Sources/MatrixScreenSaver/MatrixScreenSaverOptions.swift
@@ -1,4 +1,5 @@
import AppKit
+import CoreText
import ScreenSaver
struct MatrixScreenSaverOptions: Equatable {
@@ -26,6 +27,10 @@ struct MatrixScreenSaverOptions: Equatable {
static let frameRate = "FrameRate"
static let errorRate = "ErrorRate"
static let characters = "Characters"
+ /// Defaults key for the selected rain font families.
+ static let rainFonts = "RainFonts"
+ /// Independent of custom characters and fonts so switching presets preserves both.
+ static let rainGlyphSet = "RainGlyphSet"
static let scanLinesIntensity = "ScanLinesIntensity"
static let scanLinesVertical = "ScanLinesVertical"
}
@@ -89,11 +94,14 @@ struct MatrixScreenSaverOptions: Equatable {
]
static let defaultCharacterSizeIndex = 8
static let rainDensityDescription = "Rain density multiplier. Default: 1.0."
- static let rainRunForeverDescription = "When enabled (default), rain continuously spawns new falling lines. When disabled, rain stops starting new lines after the specified duration; lines already falling finish and fade out, then the full scene sequence restarts."
static let rainDurationSecondsDescription = "How long rain keeps starting new falling lines (1–9999 seconds) before it winds down. Default: 300 (5 minutes)."
static let frameRateDescription = "Target frame rate in fps, 1–1000. Default: 25."
static let errorRateDescription = "Character change rate factor. Default: 1.0."
static let charactersDescription = "Custom glyph set (e.g. ATGC). Empty = default."
+ /// Help text shown under the Font picker.
+ static let rainFontsDescription = "Choose a font for the default glyph set."
+ /// Default selection: no fonts, which keeps the built-in glyphs and system font.
+ static let defaultRainFonts: [String] = []
static let scanLinesDescription = "CRT scan line intensity (0–100%). Default: 25%."
var neoMessageSceneEnabled = defaultNeoMessageSceneEnabled
@@ -119,6 +127,10 @@ struct MatrixScreenSaverOptions: Equatable {
var frameRate = defaultFrameRate
var errorRate = defaultErrorRate
var characters = defaultCharacters
+ /// Zero or one selected font; the array format preserves existing saved preferences.
+ var rainFonts = defaultRainFonts
+ /// Bundled film glyphs or the existing configurable rain repertoire.
+ var rainGlyphSet: RainGlyphSet = .default
var scanLinesIntensity = defaultScanLinesIntensity
var scanLinesVertical = defaultScanLinesVertical
@@ -158,6 +170,8 @@ struct MatrixScreenSaverOptions: Equatable {
frameRate: frameRate,
errorRate: errorRate,
characters: characters,
+ rainFonts: Array(rainFonts.prefix(1)),
+ rainGlyphSet: rainGlyphSet,
neoMessageName: name,
neoMessageLines: neoMessageLines,
skipSyncDelay: isPreview
@@ -213,6 +227,11 @@ struct MatrixScreenSaverOptions: Equatable {
.map { String($0) }
.joined()
}(),
+ rainFonts: Array(rainFonts
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ .prefix(1)),
+ rainGlyphSet: rainGlyphSet,
scanLinesIntensity: max(0, min(1, scanLinesIntensity)),
scanLinesVertical: scanLinesVertical
)
@@ -241,6 +260,8 @@ struct MatrixScreenSaverOptions: Equatable {
Keys.frameRate: defaultFrameRate,
Keys.errorRate: defaultErrorRate,
Keys.characters: defaultCharacters,
+ Keys.rainFonts: defaultRainFonts,
+ Keys.rainGlyphSet: RainGlyphSet.default.rawValue,
Keys.scanLinesIntensity: defaultScanLinesIntensity,
Keys.scanLinesVertical: defaultScanLinesVertical,
])
@@ -269,6 +290,8 @@ struct MatrixScreenSaverOptions: Equatable {
frameRate: defaults.double(forKey: Keys.frameRate),
errorRate: defaults.double(forKey: Keys.errorRate),
characters: defaults.string(forKey: Keys.characters) ?? defaultCharacters,
+ rainFonts: (defaults.array(forKey: Keys.rainFonts) as? [String]) ?? defaultRainFonts,
+ rainGlyphSet: RainGlyphSet(rawValue: defaults.string(forKey: Keys.rainGlyphSet) ?? "") ?? .default,
scanLinesIntensity: defaults.double(forKey: Keys.scanLinesIntensity),
scanLinesVertical: defaults.bool(forKey: Keys.scanLinesVertical)
).sanitized()
@@ -297,12 +320,19 @@ struct MatrixScreenSaverOptions: Equatable {
defaults.set(options.frameRate, forKey: Keys.frameRate)
defaults.set(options.errorRate, forKey: Keys.errorRate)
defaults.set(options.characters, forKey: Keys.characters)
+ defaults.set(options.rainFonts, forKey: Keys.rainFonts)
+ defaults.set(options.rainGlyphSet.rawValue, forKey: Keys.rainGlyphSet)
defaults.set(options.scanLinesIntensity, forKey: Keys.scanLinesIntensity)
defaults.set(options.scanLinesVertical, forKey: Keys.scanLinesVertical)
defaults.synchronize()
}
}
+/// A top-origin document keeps the Rain options at the top when its content grows.
+private final class RainOptionsDocumentView: NSView {
+ override var isFlipped: Bool { true }
+}
+
final class MatrixScreenSaverOptionsSheetController: NSObject,
NSTextFieldDelegate, NSWindowDelegate,
NSTableViewDataSource, NSTableViewDelegate {
@@ -417,6 +447,16 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
private let frameRateField = NSTextField(string: "")
private let errorRateField = NSTextField(string: "")
private let charactersField = NSTextField(string: "")
+ /// Pull-down listing installed fonts with one selected entry.
+ private let rainFontsButton = NSPopUpButton(frame: .zero, pullsDown: true)
+ private let rainFontsResetButton = NSButton(title: "Reset", target: nil, action: nil)
+ /// Radio buttons share a parent and action for native keyboard navigation.
+ private let rainGlyphRadios = RainGlyphSet.allCases.map { NSButton(radioButtonWithTitle: $0.title, target: nil, action: nil) }
+ /// Wrapping native text uses the same font fallback as the renderer.
+ private let rainGlyphPreview = NSTextField(wrappingLabelWithString: "")
+ private var selectedRainGlyphSet: RainGlyphSet = .default
+ /// Zero or one selected font, kept in the persisted array format.
+ private var selectedRainFonts: [String] = []
private var didClose = false
/// Creates native options UI with a host-owned apply action.
@@ -465,6 +505,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
frameRateField.stringValue = Self.format(options.frameRate)
errorRateField.stringValue = Self.format(options.errorRate)
charactersField.stringValue = options.characters
+ selectedRainFonts = options.sanitized().rainFonts
+ selectedRainGlyphSet = options.rainGlyphSet
+ updateRainGlyphControls()
+ rebuildRainFontsMenu()
updateSceneControls()
sizeWindowToFitContent()
}
@@ -529,7 +573,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
rainDensityField.delegate = self
rainDurationField.delegate = self
frameRateField.delegate = self
- errorRateField.delegate = self
+ errorRateField.delegate = self
+ charactersField.delegate = self
rainRunForeverCheckbox.target = self
rainRunForeverCheckbox.action = #selector(rainRunForeverCheckboxChanged(_:))
for checkbox in [neoMessageSceneCheckbox, cmdSceneCheckbox, numberSceneCheckbox] {
@@ -571,7 +616,9 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
makeCheckboxSection(checkbox: twinkleCheckbox, description: MatrixScreenSaverOptions.twinkleDescription),
makeCheckboxSection(checkbox: diffuseCheckbox, description: MatrixScreenSaverOptions.diffuseDescription),
makeNumericSection(title: "Density", field: rainDensityField, description: MatrixScreenSaverOptions.rainDensityDescription),
- makeRainDurationSection(),
+ makeRainDurationSection(),
+ makeRainGlyphSection(),
+ makeRainFontsSection(),
makeTextSection(title: "Characters", field: charactersField, description: MatrixScreenSaverOptions.charactersDescription, fieldWidth: Self.textFieldWidth / 2),
makeNumericSection(title: "Error rate", field: errorRateField, description: MatrixScreenSaverOptions.errorRateDescription),
]))
@@ -581,7 +628,7 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
// Keep every page the same size so switching tabs never moves the dialog buttons.
NSLayoutConstraint.activate([
tabs.widthAnchor.constraint(equalToConstant: 520),
- tabs.heightAnchor.constraint(equalToConstant: 520),
+ tabs.heightAnchor.constraint(equalToConstant: 550),
tabs.widthAnchor.constraint(equalTo: rootStack.widthAnchor, constant: groupWidth),
])
@@ -1100,6 +1147,10 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
/// Saves edits before scrolling recycles a row's text field.
func controlTextDidChange(_ notification: Notification) {
guard let field = notification.object as? NSTextField else { return }
+ if field === charactersField {
+ updateRainGlyphPreview()
+ return
+ }
let row = linesTableView.row(for: field)
guard editorLines.indices.contains(row) else { return }
editorLines[row] = field.stringValue
@@ -1203,6 +1254,146 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
return stack
}
+ private func makeRainGlyphSection() -> NSView {
+ let heading = NSTextField(labelWithString: "Glyph set")
+ heading.font = .systemFont(ofSize: 13, weight: .semibold)
+ for (index, radio) in rainGlyphRadios.enumerated() {
+ radio.tag = index
+ radio.target = self
+ radio.action = #selector(rainGlyphSetChanged(_:))
+ }
+ let row = NSStackView(views: rainGlyphRadios)
+ row.spacing = 12
+ rainGlyphPreview.drawsBackground = true
+ rainGlyphPreview.backgroundColor = .black
+ rainGlyphPreview.textColor = .green
+ rainGlyphPreview.lineBreakMode = .byCharWrapping
+ rainGlyphPreview.maximumNumberOfLines = 0
+ rainGlyphPreview.setAccessibilityLabel("Selected rain glyphs")
+ let stack = NSStackView(views: [heading, row, rainGlyphPreview])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 4
+ stack.setHuggingPriority(.required, for: .vertical)
+ rainGlyphPreview.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+ row.toolTip = "Classic and Resurrections use their original bundled fonts. Default uses the settings below."
+ return stack
+ }
+
+ @objc private func rainGlyphSetChanged(_ sender: NSButton) {
+ selectedRainGlyphSet = RainGlyphSet.allCases[sender.tag]
+ updateRainGlyphControls()
+ }
+
+ /// Film glyphs require their matching font, so custom settings remain saved but inactive.
+ private func updateRainGlyphControls() {
+ for (preset, radio) in zip(RainGlyphSet.allCases, rainGlyphRadios) {
+ radio.state = preset == selectedRainGlyphSet ? .on : .off
+ }
+ let customEnabled = selectedRainGlyphSet == .default
+ rainFontsButton.isEnabled = customEnabled
+ rainFontsResetButton.isEnabled = customEnabled
+ charactersField.isEnabled = customEnabled
+ updateRainGlyphPreview()
+ }
+
+ /// Reads the renderer's actual repertoire so custom filtering and preset weighting stay consistent.
+ private func updateRainGlyphPreview() {
+ var options = MatrixScreenSaverOptions()
+ options.rainGlyphSet = selectedRainGlyphSet
+ options.rainFonts = selectedRainFonts
+ options.characters = charactersField.stringValue
+ let renderer = NativeMatrixRenderer()
+ renderer.updateConfiguration(options.sanitized().rendererConfiguration())
+ let name = renderer.activeRainFonts.first
+ rainGlyphPreview.font = name.flatMap { RainFontCatalog.font(named: $0, size: 12) }
+ ?? NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
+ rainGlyphPreview.stringValue = String(String.UnicodeScalarView(renderer.rainGlyphs))
+ rainGlyphPreview.invalidateIntrinsicContentSize()
+ }
+
+ /// Builds the rain fonts picker with every discovered font family.
+ private func makeRainFontsSection() -> NSView {
+ let stack = NSStackView()
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 4
+ stack.setHuggingPriority(.required, for: .vertical)
+
+ let heading = NSTextField(labelWithString: "Font")
+ heading.font = .systemFont(ofSize: 13, weight: .semibold)
+
+ rainFontsButton.setAccessibilityLabel("Font")
+ rainFontsButton.translatesAutoresizingMaskIntoConstraints = false
+ rainFontsButton.widthAnchor.constraint(equalToConstant: Self.textFieldWidth).isActive = true
+ (rainFontsButton.cell as? NSPopUpButtonCell)?.lineBreakMode = .byTruncatingTail
+
+ let resetButton = rainFontsResetButton
+ resetButton.target = self
+ resetButton.action = #selector(clearRainFonts(_:))
+ resetButton.bezelStyle = .rounded
+ resetButton.controlSize = .small
+ resetButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
+ resetButton.setAccessibilityLabel("Reset font to system default")
+ resetButton.toolTip = "Use the default system font"
+ let row = NSStackView(views: [rainFontsButton, resetButton])
+ row.orientation = .horizontal
+ row.alignment = .centerY
+ row.spacing = 8
+
+ stack.addArrangedSubview(heading)
+ stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainFontsDescription))
+ stack.addArrangedSubview(row)
+ stack.setCustomSpacing(8, after: stack.arrangedSubviews[1])
+ return stack
+ }
+
+ /// Rebuilds the full font menu with the current selection.
+ private func rebuildRainFontsMenu() {
+ let menu = NSMenu()
+ let systemFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium)
+ // Resolve the backing family instead of exposing the system alias or face weights.
+ let backingFont = CTFontCreateWithGraphicsFont(CTFontCopyGraphicsFont(systemFont, nil), 16, nil, nil)
+ let defaultTitle = "Default - \(CTFontCopyFamilyName(backingFont))"
+ let summary = selectedRainFonts.first ?? defaultTitle
+ menu.addItem(NSMenuItem(title: summary, action: nil, keyEquivalent: ""))
+
+ let defaultItem = NSMenuItem(title: defaultTitle, action: #selector(clearRainFonts(_:)), keyEquivalent: "")
+ defaultItem.target = self
+ defaultItem.state = selectedRainFonts.isEmpty ? .on : .off
+ menu.addItem(defaultItem)
+
+ let bundledFonts = RainGlyphSet.allCases.compactMap(\.fontName)
+ let families = RainFontCatalog.families(alwaysInclude: selectedRainFonts).filter { !bundledFonts.contains($0) }
+ for (index, group) in [bundledFonts, families].enumerated() {
+ if index == 1 { menu.addItem(.separator()) }
+ for family in group {
+ let item = NSMenuItem(title: family, action: #selector(selectRainFont(_:)), keyEquivalent: "")
+ item.target = self
+ item.representedObject = family
+ item.state = selectedRainFonts.contains(family) ? .on : .off
+ menu.addItem(item)
+ }
+ }
+ rainFontsButton.menu = menu
+ rainFontsButton.selectItem(at: 0)
+ }
+
+ /// Replaces the selected font without toggling an already selected font off.
+ @objc private func selectRainFont(_ sender: NSMenuItem) {
+ guard let family = sender.representedObject as? String else { return }
+ selectedRainFonts = [family]
+ rebuildRainFontsMenu()
+ updateRainGlyphPreview()
+ }
+
+ /// Clears the selection so the built-in glyphs and system font are used.
+ @objc private func clearRainFonts(_ sender: Any?) {
+ selectedRainFonts = []
+ rebuildRainFontsMenu()
+ updateRainGlyphPreview()
+ }
+
/// Builds a labeled text input row with its description.
private func makeTextSection(title: String, field: NSTextField, description: String, fieldWidth: CGFloat? = nil) -> NSView {
let stack = NSStackView()
@@ -1271,7 +1462,6 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
stack.addArrangedSubview(checkboxRow)
stack.addArrangedSubview(secondsRow)
- stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainRunForeverDescription))
stack.addArrangedSubview(makeDescriptionLabel(MatrixScreenSaverOptions.rainDurationSecondsDescription))
return stack
}
@@ -1326,8 +1516,23 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
private func makeOptionsTab(title: String, views: [NSView]) -> NSTabViewItem {
let item = NSTabViewItem(identifier: title)
item.label = title
- let contentView = NSView()
- item.view = contentView
+ // Start at the real page size so NSTabView never lays the page out at its 20pt placeholder width,
+ // which made the fixed-width rows report unsatisfiable constraints in the screen saver host.
+ let frame = NSRect(x: 0, y: 0, width: 520, height: 550)
+ let scrolls = title == "Rain"
+ let contentView = scrolls ? RainOptionsDocumentView(frame: frame) : NSView(frame: frame)
+ if scrolls {
+ let scrollView = NSScrollView(frame: frame)
+ scrollView.hasVerticalScroller = true
+ scrollView.autohidesScrollers = true
+ scrollView.drawsBackground = false
+ scrollView.documentView = contentView
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ contentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor).isActive = true
+ item.view = scrollView
+ } else {
+ item.view = contentView
+ }
let stack = NSStackView(views: views)
stack.orientation = .vertical
stack.alignment = .leading
@@ -1340,7 +1545,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
stack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 16),
stack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16),
stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 16),
- stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16),
+ scrolls ? stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -16)
+ : stack.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -16),
])
for view in views {
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
@@ -1410,6 +1616,8 @@ final class MatrixScreenSaverOptionsSheetController: NSObject,
frameRate: frameRate,
errorRate: errorRate,
characters: charactersField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines),
+ rainFonts: selectedRainFonts,
+ rainGlyphSet: selectedRainGlyphSet,
scanLinesIntensity: scanLinesSlider.doubleValue / 100,
scanLinesVertical: scanLinesDirectionPopup.indexOfSelectedItem == 0
).sanitized()
diff --git a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
index e320798..c938184 100644
--- a/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
+++ b/Sources/MatrixScreenSaver/MatrixScreenSaverView.swift
@@ -90,6 +90,17 @@ final class MatrixScreenSaverView: ScreenSaverView {
private var lineHeight: CGFloat = 22
private var regularFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .medium)
private var boldFont = NSFont.monospacedSystemFont(ofSize: 16, weight: .semibold)
+ /// Resolved rain fonts by family name for the current cell size.
+ private var rainFonts: [String: NSFont] = [:]
+
+ /// Resolves each family name to a font at the given size, skipping fonts that are not installed.
+ private static func makeRainFonts(names: [String], size: CGFloat) -> [String: NSFont] {
+ var fonts = [String: NSFont]()
+ for name in names {
+ if let f = RainFontCatalog.font(named: name, size: size) { fonts[name] = f }
+ }
+ return fonts
+ }
private var titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
private var textInsetY: CGFloat = 2
private var colorCache: [TerminalColor: NSColor] = [:]
@@ -111,6 +122,10 @@ final class MatrixScreenSaverView: ScreenSaverView {
let textInsetY: CGFloat
let palette: [TerminalColor]
let scalars: [UnicodeScalar]
+ /// Selected rain font families the rasters were built for.
+ let rainFonts: [String]
+ /// Sources can change while private-use keys stay the same after editing Characters.
+ let rainSources: [UnicodeScalar: UnicodeScalar]
}
/// Inputs used by the current regular and bold rasters.
private var nativeGlyphCacheKey: GlyphCacheKey?
@@ -492,6 +507,7 @@ final class MatrixScreenSaverView: ScreenSaverView {
let fontSize = fittedTerminalFontSize(forCellWidth: cellWidth, cellHeight: lineHeight)
regularFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .medium)
boldFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .semibold)
+ rainFonts = Self.makeRainFonts(names: nativeRenderer.activeRainFonts, size: fontSize)
textInsetY = max(0, floor((lineHeight - regularFont.pointSize) / 2) - 1)
rebuildNativeGlyphCache()
@@ -946,11 +962,14 @@ final class MatrixScreenSaverView: ScreenSaverView {
let palette = nativeRenderer.levelColors
let key = GlyphCacheKey(cellSize: CGSize(width: cellWidth, height: lineHeight),
regularFont: regularFont, boldFont: boldFont, textInsetY: textInsetY,
- palette: palette, scalars: nativeRenderer.supportedScalars)
+ palette: palette, scalars: nativeRenderer.supportedScalars,
+ rainFonts: nativeRenderer.activeRainFonts,
+ rainSources: nativeRenderer.rainSourceByScalar)
guard nativeGlyphCacheKey != key else { return }
nativeSupplementalGlyphs.removeAll(keepingCapacity: true)
- nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont)
- nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont)
+ nativeRegularGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: regularFont, rainFonts: rainFonts)
+ nativeBoldGlyphsByLevel = makeNativeGlyphCaches(palette: palette, font: boldFont, rainFonts: rainFonts,
+ sharingRainGlyphs: nativeRegularGlyphsByLevel)
nativeGlyphCacheKey = key
}
@@ -966,9 +985,18 @@ final class MatrixScreenSaverView: ScreenSaverView {
}
/// Builds per-level glyph images for all supported scalar values.
+ ///
+ /// - Parameters:
+ /// - palette: Colors for the cache levels.
+ /// - font: System font weight used for terminal text.
+ /// - rainFonts: Selected faces used regardless of terminal text weight.
+ /// - sharingRainGlyphs: Caches from the same palette and layout whose selected-font images can be reused.
+ /// - Returns: Glyph images indexed by palette level and renderer scalar.
private func makeNativeGlyphCaches(
palette: [TerminalColor],
- font: NSFont
+ font: NSFont,
+ rainFonts: [String: NSFont],
+ sharingRainGlyphs: [[UnicodeScalar: CGImage]] = []
) -> [[UnicodeScalar: CGImage]] {
var caches: [[UnicodeScalar: CGImage]] = Array(repeating: [:], count: palette.count)
let scalars = nativeRenderer.supportedScalars
@@ -979,7 +1007,15 @@ final class MatrixScreenSaverView: ScreenSaverView {
]
caches[level].reserveCapacity(scalars.count)
for scalar in scalars {
- guard let image = makeNativeGlyphImage(for: scalar, attributes: attributes) else {
+ var glyphAttributes = attributes
+ if let name = nativeRenderer.rainFontByScalar[scalar], let rf = rainFonts[name] {
+ if sharingRainGlyphs.indices.contains(level), let image = sharingRainGlyphs[level][scalar] {
+ caches[level][scalar] = image
+ continue
+ }
+ glyphAttributes[.font] = rf
+ }
+ guard let image = makeNativeGlyphImage(for: scalar, drawing: nativeRenderer.rainSourceByScalar[scalar], attributes: glyphAttributes) else {
continue
}
caches[level][scalar] = image
@@ -989,8 +1025,15 @@ final class MatrixScreenSaverView: ScreenSaverView {
}
/// Rasterizes a single glyph into a native Core Graphics image.
+ ///
+ /// - Parameters:
+ /// - scalar: Renderer cache key, which may be a private-use rain alias.
+ /// - drawing: Selected-font source character, enabling fitted rendering; nil keeps terminal-text rendering.
+ /// - attributes: Font and foreground color used to draw the glyph.
+ /// - Returns: The cell-sized raster, or nil if a bitmap context could not be created.
private func makeNativeGlyphImage(
for scalar: UnicodeScalar,
+ drawing: UnicodeScalar? = nil,
attributes: [NSAttributedString.Key: Any]
) -> CGImage? {
let pixelWidth = max(Int(ceil(cellWidth)), 1)
@@ -1014,7 +1057,20 @@ final class MatrixScreenSaverView: ScreenSaverView {
return glyphContext.makeImage()
}
- let text = String(scalar)
+ let text = String(drawing ?? scalar)
+ if drawing != nil {
+ let line = CTLineCreateWithAttributedString(NSAttributedString(string: text, attributes: attributes))
+ let ink = CTLineGetImageBounds(line, glyphContext)
+ guard !ink.isEmpty, !ink.isNull else { return glyphContext.makeImage() }
+ // Selected fonts can be wider or taller than the system font used to size the grid.
+ let scale = min(1, max(cellWidth - 2, 1) / ink.width, max(lineHeight - 2, 1) / ink.height)
+ glyphContext.translateBy(x: (cellWidth - ink.width * scale) / 2 - ink.minX * scale,
+ y: (lineHeight - ink.height * scale) / 2 - ink.minY * scale)
+ glyphContext.scaleBy(x: scale, y: scale)
+ glyphContext.textPosition = .zero
+ CTLineDraw(line, glyphContext)
+ return glyphContext.makeImage()
+ }
let textSize = (text as NSString).size(withAttributes: attributes)
let point = NSPoint(
x: max(0, floor((cellWidth - textSize.width) / 2)),
diff --git a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
index 51ec0e6..02ba423 100644
--- a/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
+++ b/Sources/MatrixScreenSaver/NativeMatrixRenderer.swift
@@ -1,5 +1,61 @@
+import AppKit
+import CoreText
import Foundation
+/// Rain repertoires; film presets pair their original artwork with the matching bundled font.
+enum RainGlyphSet: String, CaseIterable {
+ /// Existing built-in or user-customized rain.
+ case `default`
+ /// Original trilogy glyph artwork.
+ case classic
+ /// Expanded Matrix Resurrections glyph artwork.
+ case resurrections
+
+ /// Display label in the Rain options radio group.
+ var title: String {
+ switch self {
+ case .default: return "Default"
+ case .classic: return "Classic"
+ case .resurrections: return "Resurrections"
+ }
+ }
+
+ /// Bundled font resource name, also used as the rain cache's font identity.
+ var fontName: String? {
+ switch self {
+ case .default: return nil
+ case .classic: return "Matrix-Code"
+ case .resurrections: return "Matrix-Resurrected"
+ }
+ }
+
+ /// Website atlas slots, including Classic's blank and repeated zero to preserve their probability.
+ /// Mapping provenance is recorded in Resources/Fonts/README.md; glyph order.txt is only approximate.
+ var scalars: [UnicodeScalar]? {
+ switch self {
+ case .default: return nil
+ case .classic:
+ return Array("ホア3ウ セ¦:\"\u{E937}ミラリ╌ツテニハソ▪ー<>0|+*コシマムメモエヤキオカ7ケサスz152ヨタワ4ネヌナ98ヒ0".unicodeScalars)
+ case .resurrections:
+ return (0xE900...0xE986).compactMap(UnicodeScalar.init)
+ }
+ }
+
+ /// File-backed descriptors avoid globally registering or depending on installed font versions.
+ static let fontDescriptors: [String: NSFontDescriptor] = {
+ var fonts: [String: NSFontDescriptor] = [:]
+ let bundle = Bundle(for: NativeMatrixRenderer.self)
+ for preset in allCases {
+ guard let name = preset.fontName,
+ let url = bundle.url(forResource: name, withExtension: "ttf", subdirectory: "Fonts"),
+ let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
+ let descriptor = descriptors.first else { continue }
+ fonts[name] = descriptor as NSFontDescriptor
+ }
+ return fonts
+ }()
+}
+
final class NativeMatrixRenderer {
struct Configuration: Equatable {
var neoMessageSceneEnabled = true
@@ -21,6 +77,10 @@ final class NativeMatrixRenderer {
var frameRate = 25.0
var errorRate = 1.0
var characters = ""
+ /// Installed font families used for the rain glyphs. Empty keeps the built-in glyphs and system font.
+ var rainFonts: [String] = []
+ /// Film presets override rendering without discarding the user's custom fonts or characters.
+ var rainGlyphSet: RainGlyphSet = .default
var neoMessageName = ""
var neoMessageLines: [String] = []
/// When true the 10-second multi-screen sync delay is skipped so scenes
@@ -49,6 +109,8 @@ final class NativeMatrixRenderer {
frameRate: min(max(frameRate, MatrixRendererLimits.minimumFrameRate), MatrixRendererLimits.maximumFrameRate),
errorRate: max(errorRate, MatrixRendererLimits.minimumErrorRate),
characters: characters,
+ rainFonts: rainFonts,
+ rainGlyphSet: rainGlyphSet,
neoMessageName: neoMessageName.trimmingCharacters(in: .whitespacesAndNewlines),
neoMessageLines: neoMessageLines.isEmpty ? NeoMessageScene.lines(forName: neoMessageName) : neoMessageLines,
skipSyncDelay: skipSyncDelay
@@ -229,9 +291,12 @@ final class NativeMatrixRenderer {
private static let maxSimulationStepsPerTick = 2
private static let disableBoldFlag: UInt32 = 0x1
private static let blankRenderCell = RenderCell()
+ /// Half-width katakana characters used by the built-in rain.
+ private static let defaultKatakanaGlyphs = (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) }
+
private static let defaultGlyphPool: [UnicodeScalar] = {
var glyphs = Array("0123456789".unicodeScalars)
- glyphs.append(contentsOf: (0..<46).compactMap { UnicodeScalar(0xFF70 + $0) })
+ glyphs.append(contentsOf: defaultKatakanaGlyphs)
glyphs.append(contentsOf: "<>*+.:=_|".unicodeScalars)
// Printable ASCII for terminal text lines
glyphs.append(contentsOf: (0x20..<0x7F).compactMap { UnicodeScalar($0) })
@@ -239,13 +304,80 @@ final class NativeMatrixRenderer {
glyphs.append(UnicodeScalar(0x2588)!)
return glyphs
}()
+ /// Glyph tables derived from the rain fonts the user selected.
+ struct RainFontTables {
+ /// Glyph keys per font, selected with equal probability across fonts.
+ var pools: [[UnicodeScalar]] = []
+ /// Font family each glyph key is drawn with.
+ var fontByScalar: [UnicodeScalar: String] = [:]
+ /// Private-use glyph key to the character drawn in its selected font.
+ var sourceByScalar: [UnicodeScalar: UnicodeScalar] = [:]
+ /// Upper bound on glyphs taken from one font, sampled evenly, to keep the glyph cache small.
+ static let maximumGlyphsPerFont = 512
+ }
+
+ /// Samples a font's repertoire while retaining default or custom characters for macOS fallback.
+ /// - Parameters:
+ /// - name: Installed font family or font name.
+ /// - characters: Custom rain characters; empty uses the font's BMP repertoire.
+ /// - Returns: At most `maximumGlyphsPerFont` characters; missing glyphs are resolved at rasterization.
+ static func glyphEntries(forFont name: String, characters: String = "") -> [UnicodeScalar] {
+ guard let font = RainFontCatalog.font(named: name, size: 16) else { return [] }
+ let cs = CTFontCopyCharacterSet(font) as CharacterSet
+ let defaults = Set(defaultGlyphPool)
+ var out = [UnicodeScalar]()
+ let values = characters.isEmpty
+ ? Array(UInt32(0x21)...UInt32(0xFFFF))
+ : Array(Set(characters.unicodeScalars.map(\.value))).sorted()
+ for v in values {
+ guard let sc = UnicodeScalar(v), cs.contains(sc) || !characters.isEmpty || defaults.contains(sc),
+ v != 0xA0, v != 0x2028, v != 0x2029, v != 0x2588,
+ !CharacterSet.whitespacesAndNewlines.contains(sc),
+ !CharacterSet.controlCharacters.contains(sc) else { continue }
+ out.append(sc)
+ }
+ let limit = RainFontTables.maximumGlyphsPerFont
+ guard out.count > limit else { return out }
+ // Keep the default repertoire intact instead of sampling away characters that need fallback.
+ let required = characters.isEmpty ? out.filter { defaults.contains($0) } : []
+ let requiredSet = Set(required)
+ let remaining = out.filter { !requiredSet.contains($0) }
+ let count = limit - required.count
+ let step = Double(remaining.count) / Double(count)
+ return required + (0.. RainFontTables {
+ var tables = RainFontTables()
+ var seen = Set()
+ let names = fonts.filter { seen.insert($0).inserted }.prefix(8)
+ for (fontIndex, name) in names.enumerated() {
+ var pool = [UnicodeScalar]()
+ for (index, source) in (glyphSet.scalars ?? glyphEntries(forFont: name, characters: characters)).enumerated() {
+ // Each font owns separate keys so shared glyphs cannot steal another font's weight.
+ let key = UnicodeScalar(0xF0000 + fontIndex * RainFontTables.maximumGlyphsPerFont + index)!
+ pool.append(key)
+ tables.fontByScalar[key] = name
+ tables.sourceByScalar[key] = source
+ }
+ if !pool.isEmpty { tables.pools.append(pool) }
+ }
+ return tables
+ }
+
private static let palette = makePalette()
/// Returns the glyph pool for the given characters string, deduplicating and falling back to the default pool when empty.
- private static func resolveGlyphPool(characters: String) -> [UnicodeScalar] {
+ private static func resolveGlyphPool(characters: String, fallback: [UnicodeScalar]) -> [UnicodeScalar] {
let trimmed = characters.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
- return defaultGlyphPool
+ return fallback
}
var seen = Set()
var pool = [UnicodeScalar]()
@@ -257,7 +389,7 @@ final class NativeMatrixRenderer {
pool.append(scalar)
}
}
- return pool.isEmpty ? defaultGlyphPool : pool
+ return pool.isEmpty ? fallback : pool
}
/// The content margin (in cells) used for the text row and columns in number/neo scenes.
@@ -283,6 +415,21 @@ final class NativeMatrixRenderer {
private var configuration = Configuration().sanitized()
private var activeGlyphPool: [UnicodeScalar] = NativeMatrixRenderer.defaultGlyphPool
+ /// Active rain characters in pool order, resolving private-use cache aliases for the options preview.
+ var rainGlyphs: [UnicodeScalar] {
+ activeGlyphPool.map { rainSourceByScalar[$0] ?? $0 }
+ }
+
+ /// When set, each rain glyph picks a selected font at random, then a glyph within it.
+ private var activeMixPools: [[UnicodeScalar]]? = nil
+ /// Glyph tables for the current `activeRainFonts`.
+ private var rainTables = RainFontTables()
+ /// Rain font families the glyph tables were built for.
+ private(set) var activeRainFonts: [String] = []
+ /// Font family to draw each rain glyph key with.
+ private(set) var rainFontByScalar: [UnicodeScalar: String] = [:]
+ /// Character actually drawn for aliased glyph keys.
+ private(set) var rainSourceByScalar: [UnicodeScalar: UnicodeScalar] = [:]
var supportedScalars: [UnicodeScalar] {
let defaultSet = Set(Self.defaultGlyphPool)
@@ -521,7 +668,23 @@ final class NativeMatrixRenderer {
pendingSimulationSteps = 0.0
}
- activeGlyphPool = Self.resolveGlyphPool(characters: sanitizedConfiguration.characters)
+ let glyphsChanged = sanitizedConfiguration.rainFonts != previousConfiguration.rainFonts ||
+ sanitizedConfiguration.characters != previousConfiguration.characters ||
+ sanitizedConfiguration.rainGlyphSet != previousConfiguration.rainGlyphSet
+ if glyphsChanged {
+ activeRainFonts = sanitizedConfiguration.rainGlyphSet.fontName.map { [$0] } ?? sanitizedConfiguration.rainFonts
+ rainTables = Self.makeRainTables(fonts: activeRainFonts,
+ characters: sanitizedConfiguration.characters.trimmingCharacters(in: .whitespacesAndNewlines),
+ glyphSet: sanitizedConfiguration.rainGlyphSet)
+ rainFontByScalar = rainTables.fontByScalar
+ rainSourceByScalar = rainTables.sourceByScalar
+ }
+ let fontPoolUnion = rainTables.pools.flatMap { $0 }
+ activeGlyphPool = fontPoolUnion.isEmpty ? Self.resolveGlyphPool(
+ characters: sanitizedConfiguration.characters,
+ fallback: Self.defaultGlyphPool
+ ) : fontPoolUnion
+ activeMixPools = rainTables.pools.count > 1 ? rainTables.pools : nil
let shouldResetSceneSequence =
columns > 0 &&
@@ -539,6 +702,9 @@ final class NativeMatrixRenderer {
resetLayers()
beginSceneSequence()
} else if columns > 0, rows > 0, activeScene == .rainForever {
+ if glyphsChanged {
+ resetLayers()
+ }
constructRenderContent()
}
markAllRowsDirty()
@@ -1330,7 +1496,12 @@ final class NativeMatrixRenderer {
/// Returns a random glyph from the supported rain character pool.
private func randomGlyph() -> UnicodeScalar {
- activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0")
+ if let pools = activeMixPools,
+ let pool = pools.randomElement(using: &rainRNG),
+ let glyph = pool.randomElement(using: &rainRNG) {
+ return glyph
+ }
+ return activeGlyphPool.randomElement(using: &rainRNG) ?? UnicodeScalar("0")
}
/// Returns a random ASCII numeral for the startup number scene.
@@ -1560,3 +1731,76 @@ final class NativeMatrixRenderer {
return result
}
}
+
+/// Lists installed font families and resolves them for rain rendering.
+enum RainFontCatalog {
+ /// File-backed families omitted by the system's selectable-font enumeration.
+ private static var hiddenFonts: [String: NSFontDescriptor] = [:]
+ /// Modification dates avoid reparsing font files on every menu click.
+ private static var fontFileInventory: [URL: Date]?
+
+ /// Discovers font metadata in the standard system, local, network, and user font folders.
+ private static func refreshHiddenFonts() {
+ let manager = FileManager.default
+ let roots = manager.urls(for: .libraryDirectory, in: .allDomainsMask).map { $0.appendingPathComponent("Fonts") }
+ let keys: [URLResourceKey] = [.isRegularFileKey, .contentModificationDateKey]
+ var inventory: [URL: Date] = [:]
+ for root in roots {
+ guard let files = manager.enumerator(at: root, includingPropertiesForKeys: keys, options: [.skipsPackageDescendants]) else { continue }
+ for case let url as URL in files {
+ guard ["ttf", "otf", "ttc", "otc", "dfont", "suit"].contains(url.pathExtension.lowercased()),
+ let values = try? url.resourceValues(forKeys: Set(keys)), values.isRegularFile == true else { continue }
+ inventory[url] = values.contentModificationDate ?? .distantPast
+ }
+ }
+ guard inventory != fontFileInventory else { return }
+ let publicFamilies = Set(NSFontManager.shared.availableFontFamilies)
+ var discovered: [String: NSFontDescriptor] = [:]
+ for url in inventory.keys.sorted(by: { $0.path < $1.path }) {
+ guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { continue }
+ for descriptor in descriptors {
+ guard let family = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String,
+ !publicFamilies.contains(family) else { continue }
+ let candidate = descriptor as NSFontDescriptor
+ if let current = discovered[family], fontStyleScore(current) <= fontStyleScore(candidate) { continue }
+ discovered[family] = candidate
+ }
+ }
+ hiddenFonts = discovered
+ fontFileInventory = inventory
+ }
+
+ /// Prefers upright faces with weight nearest regular when a file contains multiple styles.
+ /// - Parameter descriptor: File-backed face to rank.
+ /// - Returns: A lower score for a more neutral style.
+ private static func fontStyleScore(_ descriptor: NSFontDescriptor) -> Double {
+ let traits = descriptor.object(forKey: .traits) as? [NSFontDescriptor.TraitKey: Any]
+ let weight = (traits?[.weight] as? NSNumber)?.doubleValue ?? 0
+ return abs(weight) + (descriptor.symbolicTraits.contains(.italic) ? 10 : 0)
+ }
+
+ /// Resolves a family, PostScript or full font name to a font.
+ static func font(named name: String, size: CGFloat) -> NSFont? {
+ if let descriptor = RainGlyphSet.fontDescriptors[name] {
+ return NSFont(descriptor: descriptor, size: size)
+ }
+ if fontFileInventory == nil { refreshHiddenFonts() }
+ if let descriptor = hiddenFonts[name] {
+ // A descriptor retains the file URL; resolving a hidden name alone may substitute Times.
+ return NSFont(descriptor: descriptor, size: size)
+ }
+ return NSFont(name: name, size: size)
+ ?? NSFontManager.shared.font(withFamily: name, traits: [], weight: 5, size: size)
+ }
+
+ /// Lists installed font families, retaining selected names for deselection.
+ /// - Parameter alwaysInclude: Selected families to keep available for deselection.
+ /// - Returns: Unique family names in localized alphabetical order.
+ static func families(alwaysInclude: [String]) -> [String] {
+ refreshHiddenFonts()
+ let all = Set(NSFontManager.shared.availableFontFamilies).union(hiddenFonts.keys)
+ var names = all
+ names.formUnion(alwaysInclude)
+ return names.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
+ }
+}
diff --git a/Sources/MatrixWallpaper/WallpaperApp.swift b/Sources/MatrixWallpaper/WallpaperApp.swift
index a92fab6..079d4d6 100644
--- a/Sources/MatrixWallpaper/WallpaperApp.swift
+++ b/Sources/MatrixWallpaper/WallpaperApp.swift
@@ -55,7 +55,10 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var isTerminating = false
/// Menu action whose label follows requested playback, not temporary suspension.
- private let playbackItem = NSMenuItem(title: "Stop", action: #selector(togglePlayback(_:)), keyEquivalent: "")
+ private let playbackButton = NSButton(title: "Stop", target: nil, action: #selector(togglePlayback(_:)))
+
+ /// Manual pause remains available independently of temporary system suspension.
+ private let pauseButton = NSButton(title: "Pause", target: nil, action: #selector(togglePause(_:)))
/// Retains native options UI independently of transient wallpaper views.
private var optionsController: MatrixScreenSaverOptionsSheetController?
@@ -104,15 +107,28 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate {
let menu = NSMenu()
menu.autoenablesItems = false
menu.delegate = self
- playbackItem.target = self
- menu.addItem(playbackItem)
+ for button in [playbackButton, pauseButton] {
+ button.target = self
+ button.bezelStyle = .rounded
+ }
+ let controls = NSStackView(views: [playbackButton, pauseButton])
+ controls.orientation = .horizontal
+ controls.alignment = .centerY
+ controls.distribution = .fillEqually
+ controls.spacing = 8
+ controls.edgeInsets = NSEdgeInsets(top: 4, left: 14, bottom: 4, right: 14)
+ controls.frame = NSRect(x: 0, y: 0, width: 240, height: 36)
+ controls.autoresizingMask = [.width]
+ let playbackRow = NSMenuItem(title: "Playback", action: nil, keyEquivalent: "")
+ playbackRow.view = controls
+ menu.addItem(playbackRow)
let options = NSMenuItem(title: "Options…", action: #selector(showOptions(_:)), keyEquivalent: "")
options.target = self
menu.addItem(options)
syncItem.target = self
- menu.addItem(syncItem)
menu.addItem(.separator())
menu.addItem(loginItems.item)
+ menu.addItem(syncItem)
menu.addItem(.separator())
let coffee = NSMenuItem(title: "Buy me a coffee ☕", action: #selector(openDonationPage(_:)), keyEquivalent: "")
coffee.target = self
@@ -206,10 +222,20 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate {
/// Starts or stops at the user's request without overriding system suspension.
@objc private func togglePlayback(_ sender: Any?) {
guard !isTerminating else { return }
+ statusItem?.menu?.cancelTracking()
pendingLaunch?.cancel()
pendingLaunch = nil
if !playback.isRequested { updateDesktopWallpaper(restoring: true) }
playback.isRequested.toggle()
+ playback.isPaused = false
+ reconcilePlayback()
+ }
+
+ /// Freezes or resumes the existing scene without releasing the user's desktop session.
+ @objc private func togglePause(_ sender: Any?) {
+ guard !isTerminating, playback.isRequested else { return }
+ statusItem?.menu?.cancelTracking()
+ playback.isPaused.toggle()
reconcilePlayback()
}
@@ -255,32 +281,19 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate {
///
/// - Parameter restart: Forces coordinated reconstruction after options or display changes.
private func reconcilePlayback(restart: Bool = false) {
- guard !isTerminating, pendingLaunch == nil else { return }
- playbackItem.title = playback.isRequested ? "Stop" : "Start"
+ guard !isTerminating else { return }
+ playbackButton.title = playback.isRequested ? "Stop" : "Start"
+ pauseButton.title = playback.isPaused ? "Resume" : "Pause"
+ pauseButton.isEnabled = playback.isRequested
+ guard pendingLaunch == nil else { return }
if !playback.isRequested { updateDesktopWallpaper(restoring: true) }
if !playback.isRequested || restart { removeDesktopWindows() }
guard playback.isRequested else { return }
- guard playback.shouldRun else {
- if suspendedAt == nil && !windows.isEmpty {
- suspendedAt = Date.timeIntervalSinceReferenceDate
- for window in windows {
- (window.contentView as? MatrixScreenSaverView)?.pauseWallpaperAnimation()
- window.orderOut(nil)
- }
- }
- return
+ if playback.shouldRun || playback.shouldShowDesktop {
+ updateDesktopWallpaper(restoring: false)
}
- updateDesktopWallpaper(restoring: false)
- guard !isTerminating, playback.shouldRun else { return }
- if let suspendedAt {
- let duration = Date.timeIntervalSinceReferenceDate - suspendedAt
- ScreenSyncCoordinator.shared.shiftTimeline(by: duration)
- for window in windows {
- (window.contentView as? MatrixScreenSaverView)?.resumeWallpaperAnimation(after: duration)
- }
- self.suspendedAt = nil
- }
- if windows.isEmpty {
+ guard !isTerminating, playback.isRequested else { return }
+ if windows.isEmpty && (playback.shouldRun || playback.shouldShowDesktop) {
currentOptions = MatrixScreenSaverView.loadSaverOptions()
displayLayout = currentDisplayLayout
for screen in NSScreen.screens {
@@ -309,6 +322,21 @@ final class WallpaperApp: NSObject, NSApplicationDelegate, NSMenuDelegate {
if !window.isVisible { window.orderBack(nil) }
} else if window.isVisible { window.orderOut(nil) }
}
+ if playback.shouldRun {
+ if let suspendedAt {
+ let duration = Date.timeIntervalSinceReferenceDate - suspendedAt
+ ScreenSyncCoordinator.shared.shiftTimeline(by: duration)
+ for window in windows {
+ (window.contentView as? MatrixScreenSaverView)?.resumeWallpaperAnimation(after: duration)
+ }
+ self.suspendedAt = nil
+ }
+ } else if suspendedAt == nil && !windows.isEmpty {
+ suspendedAt = Date.timeIntervalSinceReferenceDate
+ for window in windows {
+ (window.contentView as? MatrixScreenSaverView)?.pauseWallpaperAnimation()
+ }
+ }
}
/// Changes desktop ownership without coupling it to transient animation windows.
diff --git a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift
index 8cc5f1e..7147479 100644
--- a/Sources/MatrixWallpaper/WallpaperPlaybackState.swift
+++ b/Sources/MatrixWallpaper/WallpaperPlaybackState.swift
@@ -19,6 +19,9 @@ struct WallpaperPlaybackState {
/// Whether the user wants wallpaper playback, even if it cannot run right now.
var isRequested = true
+ /// Freezes desktop playback; a synced saver temporarily advances the same scene without clearing this choice.
+ var isPaused = false
+
/// A recent matching saver request lets the wallpaper own animation while its desktop is hidden.
var isServingScreenSaver = false
@@ -27,7 +30,8 @@ struct WallpaperPlaybackState {
/// Whether animation may run after combining user intent and system state.
var shouldRun: Bool {
- isRequested && suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) }
+ isRequested && (!isPaused || isServingScreenSaver) &&
+ suspensions.allSatisfy { isServingScreenSaver && ($0 == .screenSaver || $0 == .locked) }
}
/// Desktop visibility remains separate from rendering for the lock-screen consumer.
diff --git a/Tests/Homebrew/cask_lifecycle_test.rb b/Tests/Homebrew/cask_lifecycle_test.rb
index 6d5b3bd..c2f9b9b 100644
--- a/Tests/Homebrew/cask_lifecycle_test.rb
+++ b/Tests/Homebrew/cask_lifecycle_test.rb
@@ -100,6 +100,7 @@ def test_quarantine_failure_and_missing_source_abort
def test_no_preference_cleanup_artifact
dsl = definition
assert_equal ["MatrixWallpaper.app", "MatrixScreenSaver.saver"], dsl.artifacts.map { |a| a.source.basename.to_s }
+ assert_equal ["Matrix Wallpaper.app", "MatrixScreenSaver.saver"], dsl.artifacts.map { |a| a.target.basename.to_s }
assert_equal({quit: "dev.patsch.MatrixWallpaper", login_item: "Matrix Wallpaper"}, dsl.uninstall_directives)
source = File.read(File.expand_path("../../Homebrew/Casks/matrix-screen-saver.rb", __dir__))
refute_match(/\b(zap|defaults|unregister|launchctl)\b/, source)
@@ -111,9 +112,9 @@ def test_app_conflict_uses_custom_appdir_and_never_prints_success
dsl = definition(occupied: true, artifact_index: 0)
assert_raises(RuntimeError) { dsl.install }
assert_equal "manual", (dsl.artifacts.first.target/"executable").read
- guidance = dsl.commands.find { |_, args| args.any? { |arg| arg.include?("Existing app at #{@root}/custom apps/MatrixWallpaper.app") } }
+ guidance = dsl.commands.find { |_, args| args.any? { |arg| arg.include?("Existing app at #{@root}/custom apps/Matrix Wallpaper.app") } }
assert guidance, "missing custom appdir conflict guidance"
- assert_equal [{path: "MatrixWallpaper.app", base: :appdir}], guidance[2][:writable_paths]
+ assert_equal [{path: "Matrix Wallpaper.app", base: :appdir}], guidance[2][:writable_paths]
refute_includes dsl.messages, COFFEE
assert_equal "saved options\x00", @prefs.read
dsl.install(force: true)
diff --git a/Tests/Homebrew/documentation_test.py b/Tests/Homebrew/documentation_test.py
index 155197d..738686a 100644
--- a/Tests/Homebrew/documentation_test.py
+++ b/Tests/Homebrew/documentation_test.py
@@ -11,7 +11,7 @@ def test_user_lifecycle_and_security(self):
self.assertIn("brew trust --cask patrickschaper/tap/matrix-screen-saver", quick)
self.assertIn("brew install --cask patrickschaper/tap/matrix-screen-saver", quick)
self.assertLessEqual(len(quick.strip().splitlines()), 7)
- self.assertIn("open **MatrixWallpaper** in Applications", quick)
+ self.assertIn("open **Matrix Wallpaper** in Applications", quick)
self.assertIn("MatrixScreenSaver → Options…", quick)
self.assertNotIn("## Homebrew install", text)
self.assertNotIn("## Homebrew lifecycle", text)
diff --git a/Tests/Homebrew/fixtures/legacy-wallpaper.rb b/Tests/Homebrew/fixtures/legacy-wallpaper.rb
new file mode 100644
index 0000000..6d6766a
--- /dev/null
+++ b/Tests/Homebrew/fixtures/legacy-wallpaper.rb
@@ -0,0 +1,62 @@
+cask "matrix-screen-saver" do
+ version "0.2.0"
+ sha256 "7a60c8ec2855a2df4cb008da7f79ea9e8526385897e3048bb53bb42421e07acd"
+
+ url "https://github.com/patrickschaper/matrixScreenSaver/releases/download/#{version}/#{version}.zip"
+ name "MatrixScreenSaver"
+ desc "Native terminal-style Matrix screen saver and wallpaper"
+ homepage "https://github.com/patrickschaper/matrixScreenSaver"
+
+ depends_on arch: :arm64
+ depends_on macos: :sequoia
+
+ app "MatrixWallpaper.app"
+ screen_saver "MatrixScreenSaver.saver"
+
+ preflight_steps do
+ if_path_exists "MatrixScreenSaver.saver", base: :screen_saverdir do
+ # Homebrew also grants read access here when the native target is under HOME.
+ run "/bin/echo",
+ args: ["Existing screen saver in Homebrew's configured screen-saver directory. " \
+ "To replace it while retaining options, run: " \
+ "brew install --cask --force patrickschaper/tap/matrix-screen-saver"],
+ print_stdout: true,
+ writable_paths: [{ path: "MatrixScreenSaver.saver", base: :screen_saverdir }]
+ end
+
+ if_path_exists "MatrixWallpaper.app", base: :appdir do
+ run "/bin/echo",
+ args: ["Existing app at {{appdir}}/MatrixWallpaper.app. " \
+ "To replace it while retaining options, run: " \
+ "brew install --cask --force patrickschaper/tap/matrix-screen-saver"],
+ print_stdout: true,
+ writable_paths: [{ path: "MatrixWallpaper.app", base: :appdir }]
+ end
+
+ run "/bin/echo",
+ args: ["Removing staged quarantine bypasses Gatekeeper quarantine checks; " \
+ "this does not make either bundle notarized or Apple-approved."],
+ print_stdout: true
+ run "/usr/bin/xattr",
+ args: ["-dr", "com.apple.quarantine", "{{staged_path}}/MatrixScreenSaver.saver"],
+ writable_paths: ["{{staged_path}}/MatrixScreenSaver.saver"]
+ run "/usr/bin/xattr",
+ args: ["-dr", "com.apple.quarantine", "{{staged_path}}/MatrixWallpaper.app"],
+ writable_paths: ["{{staged_path}}/MatrixWallpaper.app"]
+ end
+
+ postflight_steps do
+ run "/bin/echo",
+ args: ["There is no spoon. There is coffee: https://www.buymeacoffee.com/yesman82"],
+ print_stdout: true
+ run "/bin/echo",
+ args: ["Open System Settings > Wallpaper > Screen Saver and select MatrixScreenSaver."],
+ print_stdout: true
+ run "/bin/echo",
+ args: ["Open {{appdir}}/MatrixWallpaper.app to start Matrix Wallpaper. " \
+ "Launch at Login is optional in its Matrix menu."],
+ print_stdout: true
+ end
+
+ uninstall quit: "dev.patsch.MatrixWallpaper", login_item: "Matrix Wallpaper"
+end
diff --git a/Tests/Homebrew/fixtures/release_fixture.py b/Tests/Homebrew/fixtures/release_fixture.py
index 6e20d76..869b7aa 100644
--- a/Tests/Homebrew/fixtures/release_fixture.py
+++ b/Tests/Homebrew/fixtures/release_fixture.py
@@ -5,7 +5,7 @@
def write_archive(path, version="1.2.3", cpu=0x0100000C, minimum=15, root="MatrixScreenSaver.saver",
- *, wallpaper=True, wallpaper_minimum=15):
+ *, wallpaper=True, wallpaper_minimum=15, wallpaper_root="Matrix Wallpaper.app"):
command = struct.pack("<6I", 0x32, 24, 1, minimum << 16, minimum << 16, 0)
binary = struct.pack("<8I", 0xFEEDFACF, cpu, 0, 8, 1, len(command), 0, 0) + command
info = plistlib.dumps({"CFBundleIdentifier": "dev.patsch.MatrixScreenSaver",
@@ -21,7 +21,7 @@ def write_archive(path, version="1.2.3", cpu=0x0100000C, minimum=15, root="Matri
info = plistlib.dumps({"CFBundleIdentifier": "dev.patsch.MatrixWallpaper",
"CFBundleShortVersionString": version, "CFBundleVersion": version,
"CFBundleExecutable": "MatrixWallpaper",
- "CFBundlePackageType": "APPL", "CFBundleName": "MatrixWallpaper"})
- for name, data in [("MatrixWallpaper.app/Contents/Info.plist", info),
- ("MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper", binary)]:
+ "CFBundlePackageType": "APPL", "CFBundleName": "Matrix Wallpaper"})
+ for name, data in [(f"{wallpaper_root}/Contents/Info.plist", info),
+ (f"{wallpaper_root}/Contents/MacOS/MatrixWallpaper", binary)]:
archive.writestr(zipfile.ZipInfo(name, (2026, 1, 1, 0, 0, 0)), data)
diff --git a/Tests/Homebrew/integration_lifecycle.sh b/Tests/Homebrew/integration_lifecycle.sh
index 11f8d94..a710df1 100644
--- a/Tests/Homebrew/integration_lifecycle.sh
+++ b/Tests/Homebrew/integration_lifecycle.sh
@@ -19,12 +19,23 @@ export HOMEBREW_DEVELOPER=1
mkdir -p "$HOMEBREW_CACHE" "$HOMEBREW_LOGS" "$HOMEBREW_TEMP"
PYTHONDONTWRITEBYTECODE=1 python3 - "$root" <<'PY'
from pathlib import Path
+import hashlib
+import importlib.util
import sys
sys.path.insert(0, "Tests/Homebrew")
from fixtures.release_fixture import write_archive
root = Path(sys.argv[1])
+write_archive(root / "1.2.1.zip", version="1.2.1", wallpaper_root="MatrixWallpaper.app")
write_archive(root / "1.2.2.zip", version="1.2.2", wallpaper=False)
+spec = importlib.util.spec_from_file_location("renderer", "Scripts/render-homebrew-release.py")
+renderer = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(renderer)
for version in ("1.2.3", "1.2.4"):
- write_archive(root / f"{version}.zip", version=version)
+ archive = root / f"{version}.zip"
+ write_archive(archive, version=version,
+ wallpaper_root="MatrixWallpaper.app" if version == "1.2.3" else "Matrix Wallpaper.app")
+ renderer.render(archive, version, renderer.asset_url(version),
+ "sha256:" + hashlib.sha256(archive.read_bytes()).hexdigest(),
+ "native lifecycle fixture", root / f"handoff-{version}")
PY
brew ruby Tests/Homebrew/support/integration_driver.rb "$root"
diff --git a/Tests/Homebrew/release_metadata_test.py b/Tests/Homebrew/release_metadata_test.py
index 5faeb24..46d5242 100644
--- a/Tests/Homebrew/release_metadata_test.py
+++ b/Tests/Homebrew/release_metadata_test.py
@@ -48,10 +48,30 @@ def test_pinned_handoff_and_deterministic_rerun(self):
cask = (self.root / "out/Casks/matrix-screen-saver.rb").read_text()
self.assertIn('version "1.2.3"', cask)
self.assertIn(manifest["sha256"], cask)
+ self.assertIn('app "Matrix Wallpaper.app", target: "Matrix Wallpaper.app"', cask)
+ self.assertIn('"{{staged_path}}/Matrix Wallpaper.app"', cask)
+ self.assertNotIn("MatrixWallpaper.app", cask)
self.assertNotIn("PLACEHOLDER", cask)
self.assertEqual(0, self.run_render().returncode)
self.assertEqual(cask, (self.root / "out/Casks/matrix-screen-saver.rb").read_text())
+ def test_immutable_old_archive_installs_with_new_name(self):
+ write_archive(self.archive, wallpaper_root="MatrixWallpaper.app")
+ result = self.run_render()
+ self.assertEqual(0, result.returncode, result.stderr)
+ cask = (self.root / "out/Casks/matrix-screen-saver.rb").read_text()
+ self.assertIn('app "MatrixWallpaper.app", target: "Matrix Wallpaper.app"', cask)
+ self.assertIn('"{{staged_path}}/MatrixWallpaper.app"', cask)
+ self.assertIn("Open {{appdir}}/Matrix Wallpaper.app", cask)
+
+ def test_rejects_archives_containing_both_app_names(self):
+ with zipfile.ZipFile(self.archive, "a") as archive:
+ archive.writestr("MatrixWallpaper.app/Contents/Info.plist", b"ambiguous")
+ result = self.run_render()
+ self.assertNotEqual(0, result.returncode)
+ self.assertIn("exactly one wallpaper app", result.stderr)
+ self.assertFalse((self.root / "out").exists())
+
def test_rejects_invalid_inputs_without_handoff(self):
cases = [dict(version="../oops"), dict(digest="sha256:" + "0" * 64),
dict(url="https://github.com/patrickschaper/matrixScreenSaver/releases/latest/download/1.2.3.zip")]
@@ -87,7 +107,7 @@ def test_rejects_missing_bundles(self):
write_archive(self.archive)
with zipfile.ZipFile(self.archive) as archive:
app = [(entry, archive.read(entry)) for entry in archive.infolist()
- if entry.filename.startswith("MatrixWallpaper.app/")]
+ if entry.filename.startswith("Matrix Wallpaper.app/")]
with zipfile.ZipFile(self.archive, "w") as archive:
for entry, data in app:
archive.writestr(entry, data)
@@ -95,8 +115,8 @@ def test_rejects_missing_bundles(self):
self.assertFalse((self.root / "out").exists())
def test_rejects_invalid_wallpaper_metadata_and_binary(self):
- plist_path = "MatrixWallpaper.app/Contents/Info.plist"
- binary_path = "MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper"
+ plist_path = "Matrix Wallpaper.app/Contents/Info.plist"
+ binary_path = "Matrix Wallpaper.app/Contents/MacOS/MatrixWallpaper"
cases = [(plist_path, key, value) for key, value in [
("CFBundleIdentifier", "dev.patsch.Other"),
("CFBundleShortVersionString", "9.9.9"), ("CFBundleVersion", "9.9.9"),
@@ -129,7 +149,7 @@ def test_rejects_oversized_archive_before_reading_entries(self):
spec = importlib.util.spec_from_file_location("render_release", SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
- entry = zipfile.ZipInfo("MatrixWallpaper.app/large")
+ entry = zipfile.ZipInfo("Matrix Wallpaper.app/large")
entry.file_size = 100_000_001
digest = "sha256:" + hashlib.sha256(self.archive.read_bytes()).hexdigest()
with patch.object(module.zipfile.ZipFile, "infolist", return_value=[entry]):
@@ -140,7 +160,7 @@ def test_rejects_oversized_archive_before_reading_entries(self):
def test_archive_guards(self):
for name, mode in [("../escape", 0), ("/absolute", 0), ("bad\\path", 0),
- ("Unexpected/file", 0), ("MatrixWallpaper.app/link", 0o120777),
+ ("Unexpected/file", 0), ("Matrix Wallpaper.app/link", 0o120777),
("MatrixScreenSaver.saver/Contents/Info.plist", 0)]:
with self.subTest(name=name):
write_archive(self.archive)
@@ -159,11 +179,11 @@ def test_release_workflow_builds_and_stages_both_bundles(self):
for job in (prepare, publish):
self.assertIn("./build.sh\n", job)
self.assertIn("./build-wallpaper.sh\n", job)
- for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"):
+ for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"):
self.assertIn(f'codesign --verify --deep --strict "build/{bundle}"', job)
self.assertLess(prepare.index('printf \'%s\\n\' "${{ steps.version.outputs.next }}" > VERSION'),
prepare.index("./build.sh\n"))
- self.assertIn('ditto "build/MatrixWallpaper.app" "${release_dir}/MatrixWallpaper.app"', publish)
+ self.assertIn('ditto "build/Matrix Wallpaper.app" "${release_dir}/Matrix Wallpaper.app"', publish)
def test_release_uses_highest_version_tag_not_nearest(self):
repository = self.root / "repository"
diff --git a/Tests/Homebrew/support/fake_cask_dsl.rb b/Tests/Homebrew/support/fake_cask_dsl.rb
index 233feab..00a4b62 100644
--- a/Tests/Homebrew/support/fake_cask_dsl.rb
+++ b/Tests/Homebrew/support/fake_cask_dsl.rb
@@ -29,8 +29,8 @@ def screen_saver(name)
@artifacts << Artifact.new(@root/"stage"/name, @root/"custom savers"/name)
end
- def app(name)
- @artifacts << Artifact.new(@root/"stage"/name, @root/"custom apps"/name)
+ def app(name, target: name)
+ @artifacts << Artifact.new(@root/"stage"/name, @root/"custom apps"/target)
end
def uninstall(**directives) = @uninstall_directives = directives
diff --git a/Tests/Homebrew/support/integration_driver.rb b/Tests/Homebrew/support/integration_driver.rb
index 38ad989..1b8290a 100644
--- a/Tests/Homebrew/support/integration_driver.rb
+++ b/Tests/Homebrew/support/integration_driver.rb
@@ -19,7 +19,8 @@
(TAP.path/"Casks").mkpath
CONFIG = Cask::Config.new(explicit: Cask::Config::DEFAULT_DIRS.keys.to_h { |key| [key, ROOT/key.to_s] })
TARGET = CONFIG.screen_saverdir/"MatrixScreenSaver.saver"
-APP_TARGET = CONFIG.appdir/"MatrixWallpaper.app"
+APP_TARGET = CONFIG.appdir/"Matrix Wallpaper.app"
+LEGACY_APP_TARGET = CONFIG.appdir/"MatrixWallpaper.app"
TARGETS = [TARGET, APP_TARGET].freeze
PREFS = ROOT/"preferences-sentinel"
PREFS.write("saved options\x00")
@@ -77,8 +78,13 @@ def capture
$stdout, $stderr = old_out, old_err
end
-def definition(version, legacy: false)
- content = Pathname(legacy ? "Tests/Homebrew/fixtures/legacy-screen-saver.rb" : "Homebrew/Casks/matrix-screen-saver.rb").read
+def definition(version, legacy: nil)
+ source = case legacy
+ when :saver then Pathname("Tests/Homebrew/fixtures/legacy-screen-saver.rb")
+ when :wallpaper then Pathname("Tests/Homebrew/fixtures/legacy-wallpaper.rb")
+ else ROOT/"handoff-#{version}/Casks/matrix-screen-saver.rb"
+ end
+ content = source.read
content.sub!('version "0.2.0"', "version \"#{version}\"")
content.sub!(/sha256 "[0-9a-f]+"/, "sha256 \"#{Digest::SHA256.file(ROOT/"#{version}.zip").hexdigest}\"")
content.sub!(/^ url .+$/, " url \"file://#{ROOT}/#{version}.zip\"")
@@ -144,7 +150,7 @@ def uninstall(cask)
uninstall(fresh)
OSASCRIPT_CALLS.clear
-legacy = definition("1.2.2", legacy: true)
+legacy = definition("1.2.2", legacy: :saver)
capture { Cask::Installer.new(legacy).install }
verify_target("1.2.2", targets: [TARGET])
check(!APP_TARGET.exist?, "Legacy fixture installed an app")
@@ -205,6 +211,43 @@ def uninstall(cask)
uninstall(reinstall)
puts "PASS AE5 native uninstall and managed reinstall preference retention"
+# The receipt owns the old filename; native upgrade must restore it on failure.
+old_name = definition("1.2.1", legacy: :wallpaper)
+capture { Cask::Installer.new(old_name).install }
+verify_target("1.2.1", targets: [TARGET, LEGACY_APP_TARGET])
+APP_TARGET.mkpath
+(APP_TARGET/"manual-executable").write("manual sentinel")
+renamed = definition("1.2.4")
+failed = false
+output = capture do
+ begin
+ Cask::Upgrade.upgrade_cask(old_name, renamed, binaries: true, force: false,
+ require_sha: true, quit: false, skip_cask_deps: false, verbose: false,
+ download_queue: Homebrew::DownloadQueue.default,
+ new_cask_installer: Cask::Installer.new(renamed, upgrade: true))
+ rescue Cask::CaskError
+ failed = true
+ end
+end
+check(failed, "Rename upgrade unexpectedly replaced an unrelated app")
+check((APP_TARGET/"manual-executable").read == "manual sentinel", "Rename upgrade changed unrelated app")
+verify_target("1.2.1", targets: [TARGET, LEGACY_APP_TARGET])
+check(output.include?("Reverting upgrade"), "Rename upgrade did not report rollback")
+FileUtils.remove_entry(APP_TARGET)
+renamed = definition("1.2.4")
+OSASCRIPT_CALLS.clear
+capture do
+ Cask::Upgrade.upgrade_cask(old_name, renamed, binaries: true, force: false,
+ require_sha: true, quit: false, skip_cask_deps: false, verbose: false,
+ download_queue: Homebrew::DownloadQueue.default,
+ new_cask_installer: Cask::Installer.new(renamed, upgrade: true))
+end
+verify_target("1.2.4")
+check(!LEGACY_APP_TARGET.exist?, "Rename upgrade left the old app filename")
+check(OSASCRIPT_CALLS.none? { |args| args.join.include?("delete every login item") }, "Rename upgrade removed login items")
+uninstall(renamed)
+puts "PASS native old-name app upgrade, conflict rollback, and preference retention"
+
# AE2/AE3: existing manual bundle survives native rejection, then explicit force replaces.
TARGETS.each do |target|
target.mkpath
diff --git a/Tests/Homebrew/tap_update_test.py b/Tests/Homebrew/tap_update_test.py
index 57b6097..2b8e919 100644
--- a/Tests/Homebrew/tap_update_test.py
+++ b/Tests/Homebrew/tap_update_test.py
@@ -8,11 +8,12 @@
SCRIPT = ROOT / "Scripts/update-homebrew-tap.py"
TEMPLATE = (ROOT / "Homebrew/Casks/matrix-screen-saver.rb").read_text()
LEGACY = (ROOT / "Tests/Homebrew/fixtures/legacy-screen-saver.rb").read_text()
+LEGACY_WALLPAPER = (ROOT / "Tests/Homebrew/fixtures/legacy-wallpaper.rb").read_text()
-def cask(version="1.2.3", digest="a" * 64, floor="sequoia"):
+def cask(version="1.2.3", digest="a" * 64, floor="sequoia", app="Matrix Wallpaper.app"):
import re
- text = re.sub(r'^ version "[^"]+"$', f' version "{version}"', TEMPLATE, flags=re.M)
+ text = re.sub(r'^ version "[^"]+"$', f' version "{version}"', TEMPLATE.replace("MatrixWallpaper.app", app), flags=re.M)
text = re.sub(r'^ sha256 "[^"]+"$', f' sha256 "{digest}"', text, flags=re.M)
return re.sub(r'^ depends_on macos: :\w+$', f' depends_on macos: :{floor}', text, flags=re.M)
@@ -42,7 +43,7 @@ def setUp(self):
spec.loader.exec_module(self.module)
def test_known_saver_only_cask_migrates_without_allowing_unrelated_changes(self):
- self.assertIn('app "MatrixWallpaper.app"', cask())
+ self.assertIn('app "Matrix Wallpaper.app", target: "Matrix Wallpaper.app"', cask())
api = FakeAPI(LEGACY)
self.module.update(api, "owner/homebrew-test", "1.2.3", cask())
self.assertEqual(cask(), api.content)
@@ -54,6 +55,22 @@ def test_known_saver_only_cask_migrates_without_allowing_unrelated_changes(self)
self.module.update(api, "owner/homebrew-test", "1.2.3", cask())
self.assertTrue(all(method == "GET" for method, _, _ in api.calls))
+ def test_old_name_dual_bundle_cask_migrates_without_allowing_unrelated_changes(self):
+ for app in ("MatrixWallpaper.app", "Matrix Wallpaper.app"):
+ with self.subTest(app=app):
+ api = FakeAPI(LEGACY_WALLPAPER)
+ self.module.update(api, "owner/homebrew-test", "1.2.3", cask(app=app))
+ self.assertEqual(cask(app=app), api.content)
+ api = FakeAPI(LEGACY_WALLPAPER + "# unreviewed change\n")
+ with self.assertRaises(ValueError):
+ self.module.update(api, "owner/homebrew-test", "1.2.3", cask(app=app))
+ self.assertTrue(all(method == "GET" for method, _, _ in api.calls))
+
+ def test_old_archive_source_upgrades_to_renamed_archive_source(self):
+ api = FakeAPI(cask("1.2.3", app="MatrixWallpaper.app"))
+ self.module.update(api, "owner/homebrew-test", "1.2.4", cask("1.2.4"))
+ self.assertEqual(cask("1.2.4"), api.content)
+
def test_legacy_migration_never_rewrites_same_or_newer_version(self):
for version in ("0.1.0", "0.2.0"):
api = FakeAPI(LEGACY)
diff --git a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift
index 99539b1..77d3f18 100644
--- a/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift
+++ b/Tests/MatrixScreenSaverTests/NativeMatrixRendererTests.swift
@@ -1,6 +1,10 @@
+import AppKit
import Foundation
func nativeMatrixRendererTests() {
+ rainFontTests()
+ rainGlyphPresetTests()
+
commandCaptureLifecycleTests()
customTraceHandoffTests()
customShellCommandSequenceTests()
@@ -135,6 +139,144 @@ func nativeMatrixRendererTests() {
ok(earlyText == startupText, "NativeMatrixRenderer: a display starting after resume keeps the same scripted content and seed")
}
+/// Covers the rain font tables and catalog using Menlo, which ships with every macOS install.
+private func rainFontTests() {
+ let tables = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"])
+ ok(tables.pools.count == 1 && !(tables.pools.first ?? []).isEmpty,
+ "RainFont: an installed font yields one non-empty glyph pool")
+ ok((tables.pools.first ?? []).count <= NativeMatrixRenderer.RainFontTables.maximumGlyphsPerFont,
+ "RainFont: a pool never exceeds the per-font glyph limit")
+ ok(tables.pools.flatMap { $0 }.allSatisfy { $0.value >= 0xF0000 && tables.sourceByScalar[$0] != nil },
+ "RainFont: every glyph is aliased so Unicode intro text keeps the system font")
+ let mixed = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo", "Monaco"], characters: "ABé")
+ ok(mixed.pools.count == 2 && mixed.pools.allSatisfy { pool in
+ Set(pool.compactMap { mixed.sourceByScalar[$0] }) == Set("ABé".unicodeScalars)
+ } && Set(mixed.pools.flatMap { $0 }).count == 6,
+ "RainFont: overlapping ASCII and Unicode characters remain available in every selected font")
+
+ ok(NativeMatrixRenderer.makeRainTables(fonts: ["No Such Font 9F3A"]).pools.isEmpty,
+ "RainFont: fonts that are not installed are skipped")
+ ok(NativeMatrixRenderer.makeRainTables(fonts: ["Menlo", "Menlo"]).pools.count == 1,
+ "RainFont: a duplicate font adds no second pool")
+
+ ok(RainFontCatalog.font(named: "Menlo", size: 12) != nil && RainFontCatalog.font(named: "No Such Font 9F3A", size: 12) == nil,
+ "RainFontCatalog: resolves installed fonts and rejects missing ones")
+ let everything = RainFontCatalog.families(alwaysInclude: [])
+ ok(Set(NSFontManager.shared.availableFontFamilies).isSubset(of: Set(everything)) && everything == everything.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending },
+ "RainFontCatalog: always lists every installed family in sorted order")
+ let systemFiles = FileManager.default.enumerator(at: URL(fileURLWithPath: "/System/Library/Fonts"), includingPropertiesForKeys: nil)!
+ var checkedHiddenFont = false
+ for case let url as URL in systemFiles {
+ guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else { continue }
+ for descriptor in descriptors {
+ guard let family = CTFontDescriptorCopyAttribute(descriptor, kCTFontFamilyNameAttribute) as? String,
+ !NSFontManager.shared.availableFontFamilies.contains(family),
+ let resolved = RainFontCatalog.font(named: family, size: 16) else { continue }
+ let actualFamily = CTFontCopyFamilyName(resolved) as String
+ ok(everything.contains(family) && actualFamily == family,
+ "RainFontCatalog: automatically discovers hidden file-backed families and resolves their actual font")
+ checkedHiddenFont = true
+ break
+ }
+ if checkedHiddenFont { break }
+ }
+ ok(checkedHiddenFont, "RainFontCatalog: exercises an installed hidden system font")
+ ok(RainFontCatalog.families(alwaysInclude: ["Chosen Font"]).contains("Chosen Font"),
+ "RainFontCatalog: a missing selected font stays available for deselection")
+ let defaultKatakana = Set((UInt32(0xFF70)...UInt32(0xFF9D)).compactMap(UnicodeScalar.init))
+ let menlo = RainFontCatalog.font(named: "Menlo", size: 16)!
+ let menloCoverage = CTFontCopyCharacterSet(menlo) as CharacterSet
+ ok(!defaultKatakana.allSatisfy { menloCoverage.contains($0) } &&
+ defaultKatakana.isSubset(of: Set(tables.sourceByScalar.values)),
+ "RainFont: default katakana missing from the selected font stay in the pool for fallback")
+ let customFallback = NativeMatrixRenderer.makeRainTables(fonts: ["Menlo"], characters: "Aア")
+ ok(Set(customFallback.sourceByScalar.values) == Set("Aア".unicodeScalars),
+ "RainFont: custom characters missing from the selected font stay in the pool for fallback")
+
+ let renderer = NativeMatrixRenderer()
+ var config = NativeMatrixRenderer.Configuration()
+ config.rainFonts = ["Menlo"]
+ ok(config.sanitized().rainFonts == ["Menlo"], "RainFont: configuration sanitizing keeps the selected fonts")
+ renderer.updateConfiguration(config)
+ ok(renderer.activeRainFonts == ["Menlo"] && !renderer.rainSourceByScalar.isEmpty && renderer.rainFontByScalar.values.allSatisfy { $0 == "Menlo" },
+ "RainFont: the renderer builds glyph tables for the selected fonts")
+ ok(renderer.rainSourceByScalar.keys.allSatisfy { renderer.supportedScalars.contains($0) },
+ "RainFont: the renderer supports the aliased rain glyphs")
+ config.characters = "A"
+ renderer.updateConfiguration(config)
+ ok(Set(renderer.rainSourceByScalar.values) == Set("A".unicodeScalars),
+ "RainFont: custom characters use the selected font")
+ config.characters = "B"
+ renderer.updateConfiguration(config)
+ ok(Set(renderer.rainSourceByScalar.values) == Set("B".unicodeScalars),
+ "RainFont: editing characters rebuilds the font table without changing fonts")
+ config.rainFonts = []
+ renderer.updateConfiguration(config)
+ ok(renderer.activeRainFonts.isEmpty && renderer.rainFontByScalar.isEmpty,
+ "RainFont: clearing the selection drops the font glyph tables")
+
+ config.rainFonts = ["Menlo", "Hiragino Sans"]
+ config.characters = "A" + String(String.UnicodeScalarView((0x30A1...0x30FA).compactMap { UnicodeScalar($0) }))
+ config.neoMessageSceneEnabled = false
+ config.numberSceneEnabled = false
+ config.cmdSceneEnabled = false
+ config.twinkleEnabled = false
+ let mixedFallback = NativeMatrixRenderer.makeRainTables(fonts: config.rainFonts, characters: config.characters)
+ ok(mixedFallback.pools.count == 2 && mixedFallback.pools.allSatisfy { pool in
+ Set(pool.compactMap { mixedFallback.sourceByScalar[$0] }) == Set(config.characters.unicodeScalars)
+ }, "RainFont: every selected font retains the full custom set regardless of native coverage")
+ renderer.updateConfiguration(config)
+ renderer.resize(to: TerminalSize(columns: 80, rows: 24))
+ renderer.overrideSceneStartTime = 1_000
+ renderer.start()
+ defer { renderer.stop() }
+ var counts: [String: Int] = [:]
+ for _ in 0..<120 {
+ renderer.shiftTimeline(by: -0.1)
+ renderer.advance()
+ for row in 0..<24 {
+ for column in 0..<80 {
+ let cell = renderer[row, column]
+ if cell.foregroundLevel > 0, let font = renderer.rainFontByScalar[cell.scalar] {
+ counts[font, default: 0] += 1
+ }
+ }
+ }
+ }
+ let total = counts.values.reduce(0, +)
+ let menloShare = Double(counts["Menlo", default: 0]) / Double(max(total, 1))
+ ok(total > 1_000 && (0.35...0.65).contains(menloShare),
+ "RainFont: rendered rain mixes selected fonts evenly with fallback-enabled glyph pools")
+}
+
+/// Exercises bundled preset coverage and switching back to the retained custom rain settings.
+private func rainGlyphPresetTests() {
+ let renderer = NativeMatrixRenderer()
+ var config = NativeMatrixRenderer.Configuration()
+ config.rainFonts = ["Menlo"]
+ config.characters = "ATGC"
+ renderer.updateConfiguration(config)
+ let customSources = renderer.rainSourceByScalar
+ for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] {
+ config.rainGlyphSet = preset
+ renderer.updateConfiguration(config)
+ ok(renderer.rainSourceByScalar.count == count && renderer.activeRainFonts == [preset.fontName!],
+ "Rain presets: \(preset.rawValue) uses its complete bundled glyph set instead of custom characters")
+ let font = RainFontCatalog.font(named: preset.fontName!, size: 16)
+ let loadedURL = font.flatMap { CTFontCopyAttribute($0, kCTFontURLAttribute) as? URL }
+ let bundledURL = Bundle(for: NativeMatrixRenderer.self).url(forResource: preset.fontName!, withExtension: "ttf", subdirectory: "Fonts")
+ let sources = Array(renderer.rainSourceByScalar.values)
+ let coverage = font.map { CTFontCopyCharacterSet($0) as CharacterSet }
+ ok(loadedURL != nil && loadedURL?.resolvingSymlinksInPath() == bundledURL?.resolvingSymlinksInPath() &&
+ sources.count == count && sources.allSatisfy { coverage?.contains($0) == true },
+ "Rain presets: \(preset.rawValue) resolves every glyph from its bundled font")
+ }
+ config.rainGlyphSet = .default
+ renderer.updateConfiguration(config)
+ ok(renderer.activeRainFonts == ["Menlo"] && renderer.rainSourceByScalar == customSources,
+ "Rain presets: Default restores the custom font and character pool")
+}
+
/// Checks idle intro frames and cursor-only updates through the host's dirty-row contract.
private func introDirtyRowTests() {
for neo in [true, false] {
@@ -146,6 +288,8 @@ private func introDirtyRowTests() {
renderer.updateConfiguration(configuration)
renderer.resize(to: TerminalSize(columns: 40, rows: 12))
renderer.overrideSceneStartTime = Date.timeIntervalSinceReferenceDate
+ // This seed finishes typing X before 0.1s, without a random hesitation between the idle frames.
+ renderer.overrideSceneContentStartTime = 1000
renderer.start()
defer { renderer.stop() }
renderer.shiftTimeline(by: -0.1)
@@ -208,6 +352,9 @@ private func traceFrameRateTests() {
renderer.shiftTimeline(by: Date.timeIntervalSinceReferenceDate - anchor - target)
renderer.advance()
}
+ // Sample past the tick boundary so floating-point rounding cannot leave one renderer a frame behind.
+ renderer.shiftTimeline(by: -0.001)
+ renderer.advance()
frames.append((0..<12).flatMap { row in (0..<40).map { renderer[row, $0] } })
}
ok(frames[0].contains(where: \.hasForeground), "Trace scrolling: comparison includes visible digits")
@@ -402,7 +549,8 @@ private func customScrollingViewportTests() {
var scrolled = false
var cleared = false
var next = false
- let deadline = Date(timeIntervalSinceNow: 6)
+ // Allow shared startup and both command schedules, as in the sequence test below.
+ let deadline = Date(timeIntervalSinceNow: 20)
while !next && Date() < deadline {
RunLoop.current.run(until: Date(timeIntervalSinceNow: 0.01))
renderer.shiftTimeline(by: -0.1)
diff --git a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift
index f91497e..89eddeb 100644
--- a/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift
+++ b/Tests/MatrixScreenSaverTests/WallpaperPlaybackStateTests.swift
@@ -4,6 +4,31 @@ import Foundation
func wallpaperPlaybackStateTests() {
var state = WallpaperPlaybackState()
ok(state.shouldRun, "WallpaperPlaybackState: launches with playback requested")
+ state.isPaused = true
+ ok(!state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: manual pause keeps the desktop visible")
+ state.setSuspended(.sleeping, true)
+ ok(!state.shouldRun && !state.shouldShowDesktop, "WallpaperPlaybackState: sleep hides a paused desktop")
+ state.setSuspended(.sleeping, false)
+ ok(!state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: wake preserves manual pause")
+ state.isServingScreenSaver = true
+ state.setSuspended(.locked, true)
+ ok(state.shouldRun && state.isPaused && !state.shouldShowDesktop, "WallpaperPlaybackState: saver sharing temporarily advances a manually paused scene")
+ for reason in [WallpaperPlaybackState.Suspension.sleeping, .displaysSleeping, .sessionInactive] {
+ state.setSuspended(reason, true)
+ ok(!state.shouldRun, "WallpaperPlaybackState: paused sharing respects \(reason)")
+ state.setSuspended(reason, false)
+ }
+ state.isRequested = false
+ ok(!state.shouldRun, "WallpaperPlaybackState: sharing cannot restart a stopped wallpaper")
+ state.isRequested = true
+ state.isServingScreenSaver = false
+ state.setSuspended(.locked, false)
+ ok(!state.shouldRun && state.isPaused && state.shouldShowDesktop, "WallpaperPlaybackState: saver exit restores the paused desktop")
+ state.setSuspended(.locked, true)
+ state.isPaused = false
+ ok(!state.shouldRun, "WallpaperPlaybackState: manual resume cannot override lock")
+ state.setSuspended(.locked, false)
+ ok(state.shouldRun && state.shouldShowDesktop, "WallpaperPlaybackState: resume runs after system suspension clears")
state.setSuspended(.locked, true)
state.setSuspended(.locked, true)
state.setSuspended(.sleeping, true)
diff --git a/Tests/MatrixWallpaperTests/SharedSaverTests.swift b/Tests/MatrixWallpaperTests/SharedSaverTests.swift
index 8400464..3f585c7 100644
--- a/Tests/MatrixWallpaperTests/SharedSaverTests.swift
+++ b/Tests/MatrixWallpaperTests/SharedSaverTests.swift
@@ -4,6 +4,8 @@ import ScreenSaver
/// Exercises the shared view and options controller through their hosting boundaries.
func sharedSaverTests() {
glyphCacheTests()
+ rainFontResetTests()
+ rainGlyphOptionsTests()
firstSceneSynchronizationTests()
guard let screen = NSScreen.main,
let first = MatrixScreenSaverView(frame: screen.frame, isPreview: false),
@@ -134,6 +136,21 @@ func sharedSaverTests() {
options.save(to: defaults)
check(MatrixScreenSaverOptions.load(from: defaults).frameRate > 0, "shared persistence retains option sanitization")
+ var fontOptions = MatrixScreenSaverOptions()
+ check(fontOptions.rainFonts.isEmpty, "rain fonts default to none, keeping the built-in glyphs")
+ fontOptions.rainFonts = [" Matrix-Code ", "Matrix-Code", "", "Matrix-Resurrected"] + (1...10).map { "Font \($0)" }
+ let cleanFonts = fontOptions.sanitized().rainFonts
+ check(cleanFonts == ["Matrix-Code"], "rain font sanitizing retains only the first nonempty trimmed name")
+ defaults.set(["", " Menlo ", "Hiragino Sans"], forKey: "RainFonts")
+ check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Menlo"],
+ "existing multi-font preferences migrate to the first nonempty font")
+ fontOptions.rainFonts = ["Matrix-Code", "Matrix-Resurrected"]
+ fontOptions.save(to: defaults)
+ check(MatrixScreenSaverOptions.load(from: defaults).rainFonts == ["Matrix-Code"],
+ "only one rain font persists through save and load")
+ check(fontOptions.rendererConfiguration(isPreview: false).rainFonts == ["Matrix-Code"],
+ "only one selected font reaches the renderer configuration")
+
let original = MatrixScreenSaverOptions()
var commandOptions = original
commandOptions.commands = [String(repeating: "x", count: 513), "printf safe", "printf bad\0ignored"]
@@ -229,6 +246,135 @@ private func firstSceneSynchronizationTests() {
}
}
+/// Checks that the font reset updates the menu and accepted options without resetting other settings.
+private func rainFontResetTests() {
+ var applied: MatrixScreenSaverOptions?
+ let controller = MatrixScreenSaverOptionsSheetController { applied = $0 }
+ var options = MatrixScreenSaverOptions()
+ options.rainFonts = ["Menlo", "Hiragino Sans"]
+ options.characters = "ATGC"
+ options.frameRate = 42
+ controller.prepare(using: options)
+ let window = controller.configureSheet()
+ let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first!
+ tabs.selectTabViewItem(tabs.tabViewItems.first { $0.label == "Rain" }!)
+ window.contentView!.layoutSubtreeIfNeeded()
+ let picker: NSPopUpButton? = stored("rainFontsButton", in: controller)
+ let reset = buttons(in: window.contentView!).first { $0.title == "Reset" }
+ let bundledNames = ["Matrix-Code", "Matrix-Resurrected"]
+ let items = picker?.menu?.items ?? []
+ check(items.count > 4 && Array(items.dropFirst(2).prefix(2).map(\.title)) == bundledNames && items[4].isSeparatorItem &&
+ bundledNames.allSatisfy { name in items.filter { $0.representedObject as? String == name }.count == 1 },
+ "bundled fonts appear once directly after Default and before the divider")
+ check(reset?.controlSize == .small && picker?.title == "Menlo" &&
+ picker?.menu?.items.filter { $0.state == .on }.count == 1,
+ "font picker exposes a small reset button beside a custom selection")
+ for _ in 0..<2 {
+ if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) {
+ menu.performActionForItem(at: index)
+ }
+ check(picker?.title == "Hiragino Sans" && picker?.menu?.items.filter { $0.state == .on }.count == 1 &&
+ picker?.menu?.items.first { $0.title == "Menlo" }?.state == .off,
+ "choosing a font replaces the previous selection and selecting it again keeps it selected")
+ }
+ for name in bundledNames {
+ if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == name }) {
+ menu.performActionForItem(at: index)
+ }
+ check(picker?.title == name && picker?.menu?.items.filter { $0.state == .on }.count == 1,
+ "bundled font \(name) can be selected from the dropdown")
+ }
+ reset?.performClick(nil)
+ check(picker?.title.hasPrefix("Default - ") == true && picker?.menu?.items[1].state == .on &&
+ picker?.menu?.items.filter { $0.representedObject is String }.allSatisfy { $0.state == .off } == true,
+ "font reset restores the system-font summary and clears custom-font checkmarks")
+ buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil)
+ check(applied?.rainFonts.isEmpty == true && applied?.characters == "ATGC" && applied?.frameRate == 42,
+ "font reset applies the default font without changing other options")
+}
+
+/// Exercises preset persistence, exclusive radio selection, cancellation and retained customization.
+private func rainGlyphOptionsTests() {
+ let domain = "dev.patsch.MatrixWallpaper.glyph-tests.\(UUID().uuidString)"
+ let defaults = ScreenSaverDefaults(forModuleWithName: domain)!
+ defer { defaults.removePersistentDomain(forName: domain); defaults.synchronize() }
+ MatrixScreenSaverOptions.registerDefaults(in: defaults)
+ check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default,
+ "existing installations keep the default glyph set")
+ defaults.set("unknown-preset", forKey: "RainGlyphSet")
+ check(MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .default,
+ "unknown persisted glyph sets safely use Default")
+ var options = MatrixScreenSaverOptions()
+ options.characters = "ATGC"
+ options.rainFonts = ["Menlo"]
+ for preset in RainGlyphSet.allCases {
+ options.rainGlyphSet = preset
+ options.save(to: defaults)
+ let loaded = MatrixScreenSaverOptions.load(from: defaults)
+ check(loaded.rainGlyphSet == preset && loaded.rendererConfiguration().rainGlyphSet == preset &&
+ loaded.characters == "ATGC" && loaded.rainFonts == ["Menlo"],
+ "\(preset.title) persists and reaches the renderer without overwriting customization")
+ }
+ var applied: MatrixScreenSaverOptions?
+ let controller = MatrixScreenSaverOptionsSheetController { applied = $0 }
+ controller.prepare(using: options)
+ let window = controller.configureSheet()
+ let tabs = descendants(of: window.contentView!).compactMap { $0 as? NSTabView }.first!
+ let rainTab = tabs.tabViewItems.first { $0.label == "Rain" }!
+ tabs.selectTabViewItem(rainTab)
+ let controls = buttons(in: rainTab.view!)
+ let radios = RainGlyphSet.allCases.compactMap { preset in controls.first { $0.title == preset.title } }
+ let picker: NSPopUpButton? = stored("rainFontsButton", in: controller)
+ let characters: NSTextField? = stored("charactersField", in: controller)
+ let reset = controls.first { $0.title == "Reset" }
+ let preview: NSTextField? = stored("rainGlyphPreview", in: controller)
+ check(radios.count == 3 && radios.last?.state == .on && picker?.isEnabled == false,
+ "Rain restores the saved Resurrections radio with custom font controls inactive")
+ guard radios.count == 3 else { return }
+ for (index, radio) in radios.enumerated() {
+ radio.performClick(nil)
+ check(radios.enumerated().allSatisfy { $0.element.state == ($0.offset == index ? .on : .off) },
+ "\(radio.title) is mutually exclusive with the other glyph presets")
+ check(picker?.isEnabled == (index == 0) && characters?.isEnabled == (index == 0) && reset?.isEnabled == (index == 0),
+ "\(radio.title) enables custom settings only for Default")
+ let expectedCount = [4, 57, 135][index]
+ check(preview?.stringValue.unicodeScalars.count == expectedCount && preview?.drawsBackground == true &&
+ preview?.backgroundColor == .black && preview?.textColor == .green,
+ "\(radio.title) previews every active glyph in green on black")
+ let expectedFont = index == 0 ? "Menlo-Regular" : ["Matrix-Code", "Matrix-Resurrected"][index - 1]
+ check(preview?.font?.fontName == expectedFont, "\(radio.title) preview uses its current rendering font")
+ }
+ window.contentView!.layoutSubtreeIfNeeded()
+ check(preview != nil && preview!.frame.height > (preview!.font?.pointSize ?? 0) * 1.5 && (rainTab.view as? NSScrollView)?.hasVerticalScroller == true,
+ "long glyph sets wrap onto multiple lines in a scrollable Rain tab")
+ buttons(in: window.contentView!).first { $0.title == "OK" }?.performClick(nil)
+ check(applied?.rainGlyphSet == .resurrections && applied?.rainFonts == ["Menlo"] && applied?.characters == "ATGC",
+ "accepting a film preset keeps custom fonts and characters for switching back")
+ controller.prepare(using: applied!)
+ radios[0].performClick(nil)
+ check(characters?.stringValue == "ATGC" && picker?.title.contains("Menlo") == true,
+ "switching back to Default restores editable custom settings")
+ characters?.stringValue = "XYZ"
+ controller.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification, object: characters))
+ check(preview?.stringValue == "XYZ", "editing custom characters refreshes the glyph preview immediately")
+ if let menu = picker?.menu, let index = menu.items.firstIndex(where: { $0.representedObject as? String == "Hiragino Sans" }) {
+ menu.performActionForItem(at: index)
+ }
+ check(preview?.font?.familyName == "Hiragino Sans", "choosing another font refreshes the preview typeface")
+ reset?.performClick(nil)
+ check(preview?.font?.fontName == NSFont.monospacedSystemFont(ofSize: 16, weight: .medium).fontName,
+ "font Reset restores the system typeface in the preview")
+ var cancelled = false
+ let cancelController = MatrixScreenSaverOptionsSheetController { _ in cancelled = true }
+ cancelController.prepare(using: options)
+ buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Reset to defaults" }?.performClick(nil)
+ let resetRadios: [NSButton] = stored("rainGlyphRadios", in: cancelController) ?? []
+ check(resetRadios.first?.state == .on, "Reset to defaults restores the default glyph set")
+ buttons(in: cancelController.configureSheet().contentView!).first { $0.title == "Cancel" }?.performClick(nil)
+ check(!cancelled && MatrixScreenSaverOptions.load(from: defaults).rainGlyphSet == .resurrections,
+ "Cancel does not persist a changed glyph preset")
+}
+
/// Checks raster reuse through layout and option updates, retaining images to compare identity safely.
private func glyphCacheTests() {
let view = MatrixScreenSaverView(frame: NSRect(x: 0, y: 0, width: 640, height: 360), isPreview: true)!
@@ -248,6 +394,8 @@ private func glyphCacheTests() {
options.frameRate = 42
view.applyWallpaperOptions(options)
check(original === glyph("A"), "frame-rate changes preserve glyph rasters")
+ // These checks exercise plain scalar keys, independent of the user's saved font selection.
+ options.rainFonts = []
options.characters = "☃"
view.applyWallpaperOptions(options)
check(glyph("☃") != nil && glyph("☃", bold: true) != nil,
@@ -268,6 +416,83 @@ private func glyphCacheTests() {
check(glyph("A") != nil && glyph("A", bold: true) != nil,
"restarting after resource cleanup recreates usable glyph caches")
view.stopAnimation()
+ options.rainFonts = ["Menlo"]
+ options.characters = "A"
+ view.applyWallpaperOptions(options)
+ let rainKey = UnicodeScalar(0xF0000)!
+ let rainA = glyph(rainKey)
+ let regularLevels: [[UnicodeScalar: CGImage]] = stored("nativeRegularGlyphsByLevel", in: view) ?? []
+ let boldLevels: [[UnicodeScalar: CGImage]] = stored("nativeBoldGlyphsByLevel", in: view) ?? []
+ check(!regularLevels.isEmpty && regularLevels.count == boldLevels.count &&
+ zip(regularLevels, boldLevels).allSatisfy { regular, bold in
+ regular[rainKey] != nil && regular[rainKey] === bold[rainKey] &&
+ regular["A"] != nil && bold["A"] != nil && regular["A"] !== bold["A"]
+ }, "selected-font rasters share every weight-cache level while system bold stays separate")
+ options.characters = "B"
+ view.applyWallpaperOptions(options)
+ check(rainA != nil && glyph(rainKey) != nil && rainA !== glyph(rainKey),
+ "editing custom rain characters refreshes rasters even when alias keys stay unchanged")
+ options.characters = "ア"
+ view.applyWallpaperOptions(options)
+ let fallbackImage = glyph(rainKey)
+ let selectedFonts: [String: NSFont] = stored("rainFonts", in: view) ?? [:]
+ let selectedFont = selectedFonts["Menlo"]!
+ let line = CTLineCreateWithAttributedString(NSAttributedString(string: "ア", attributes: [.font: selectedFont]))
+ let runs = CTLineGetGlyphRuns(line) as! [CTRun]
+ let usesFallback = runs.contains { run in
+ let font = (CTRunGetAttributes(run) as NSDictionary)[kCTFontAttributeName] as! CTFont
+ var glyphID: CGGlyph = 0
+ CTRunGetGlyphs(run, CFRange(location: 0, length: 1), &glyphID)
+ return CTFontCopyFamilyName(font) != CTFontCopyFamilyName(selectedFont) && glyphID != 0
+ }
+ let hasFallbackInk = fallbackImage.map { image in
+ let bitmap = NSBitmapImageRep(cgImage: image)
+ return (0.. 0 }
+ }
+ } ?? false
+ check(usesFallback && hasFallbackInk, "missing selected-font katakana resolves to a real fallback glyph and a visible cached raster")
+ view.applyWallpaperOptions(options)
+ check(fallbackImage != nil && fallbackImage === glyph(rainKey), "unchanged options reuse the fallback raster without resolving fonts again")
+ options.rainFonts = ["Hiragino Sans"]
+ options.characters = "カ"
+ options.characterWidth = 16
+ options.characterHeight = 30
+ view.applyWallpaperOptions(options)
+ if let image = glyph(rainKey) {
+ let bitmap = NSBitmapImageRep(cgImage: image)
+ // Core Text antialiasing can reach past its measured ink bounds; solid strokes must not.
+ let edgesHaveNoSolidInk = (0.. 0 }
+ }
+ check(edgesHaveNoSolidInk && hasInk, "wide selected-font glyphs fit inside the cell without cutting solid strokes")
+ } else {
+ check(false, "wide selected-font glyphs fit inside the cell without cutting solid strokes")
+ }
+ for (preset, count) in [(RainGlyphSet.classic, 57), (.resurrections, 135)] {
+ let previousRaster = glyph(rainKey)
+ options.rainGlyphSet = preset
+ view.applyWallpaperOptions(options)
+ let renderer: NativeMatrixRenderer = stored("nativeRenderer", in: view)!
+ let resolved: [String: NSFont] = stored("rainFonts", in: view) ?? [:]
+ let font = resolved[preset.fontName!]
+ let usesOriginalFont = font.map { CTFontCopyPostScriptName($0) as String == preset.fontName } ?? false
+ check(usesOriginalFont && renderer.rainSourceByScalar.count == count &&
+ renderer.rainSourceByScalar.keys.allSatisfy { glyph($0) != nil } && previousRaster !== glyph(rainKey),
+ "\(preset.title) rebuilds all preset rasters using the original bundled font")
+ let cached = glyph(rainKey)
+ view.applyWallpaperOptions(options)
+ check(cached != nil && cached === glyph(rainKey) && cached === glyph(rainKey, bold: true),
+ "\(preset.title) reuses cached artwork across unchanged options and font weights")
+ }
+ options.rainGlyphSet = .default
+ view.applyWallpaperOptions(options)
+ check(glyph(UnicodeScalar(0xF0001)!) == nil && glyph(rainKey) != nil,
+ "returning to custom rain removes stale film-preset glyphs")
var capturedOptions = MatrixScreenSaverOptions()
capturedOptions.neoMessageSceneEnabled = false
capturedOptions.cmdSceneEnabled = true
@@ -275,6 +500,7 @@ private func glyphCacheTests() {
capturedOptions.commands = ["printf '\\303\\251'"]
capturedOptions.neoMessageSpeedFactor = 100
capturedOptions.scanLinesIntensity = 0
+ capturedOptions.rainFonts = ["Menlo"]
let container = NSView(frame: view.frame)
container.addSubview(view)
defer { view.removeFromSuperview() }
@@ -299,7 +525,8 @@ private func checkOptionsLayout(in window: NSWindow) {
let content = window.contentView!
content.layoutSubtreeIfNeeded()
let size = content.bounds.size
- check(size.width <= 600 && size.height <= 600,
+ // The Rain tab gained the Fonts section, so the sheet is taller than the original 600 point budget.
+ check(size.width <= 600 && size.height <= 650,
"options fit in a compact window without screen-sized help text")
guard let tabs = descendants(of: content).compactMap({ $0 as? NSTabView }).first else {
check(false, "options organize settings into native tabs")
@@ -310,16 +537,20 @@ private func checkOptionsLayout(in window: NSWindow) {
for item in tabs.tabViewItems {
tabs.selectTabViewItem(item)
content.layoutSubtreeIfNeeded()
- let page = item.view!
+ let page = (item.view as? NSScrollView)?.documentView ?? item.view!
let controls = descendants(of: page).compactMap { $0 as? NSControl }
check(!controls.isEmpty && controls.allSatisfy { page.bounds.contains($0.convert($0.bounds, to: page)) },
"\(item.label) keeps every control and help label inside its page")
let sections = (page.subviews.first as? NSStackView)?.arrangedSubviews ?? []
- let top = sections.map { $0.convert($0.bounds, to: page).maxY }.max() ?? 0
- check((12...20).contains(page.bounds.maxY - top), "\(item.label) starts with a consistent small top margin")
+ let frames = sections.map { $0.convert($0.bounds, to: page) }
+ let topMargin = page.isFlipped ? (frames.map(\.minY).min() ?? 0) - page.bounds.minY
+ : page.bounds.maxY - (frames.map(\.maxY).max() ?? 0)
+ check((12...20).contains(topMargin), "\(item.label) starts with a consistent small top margin")
if let stack = page.subviews.first as? NSStackView {
let sections = stack.arrangedSubviews
- check(zip(sections, sections.dropFirst()).allSatisfy { $0.frame.minY >= $1.frame.maxY },
+ check(zip(sections, sections.dropFirst()).allSatisfy {
+ stack.isFlipped ? $0.frame.maxY <= $1.frame.minY : $0.frame.minY >= $1.frame.maxY
+ },
"\(item.label) sections do not overlap")
}
if let forever = controls.compactMap({ $0 as? NSButton }).first(where: { $0.title == "Run forever" }),
diff --git a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift
index b42953b..dd1fcd0 100644
--- a/Tests/MatrixWallpaperTests/WallpaperAppTests.swift
+++ b/Tests/MatrixWallpaperTests/WallpaperAppTests.swift
@@ -15,12 +15,30 @@ func wallpaperAppTests() {
host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
defer { host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) }
let status: NSStatusItem? = stored("statusItem", in: host)
- check(status?.menu?.items.first?.title == "Stop", "launch installs playback controls before returning")
+ let controls = status?.menu?.items.first?.view as? NSStackView
+ let buttons = controls?.arrangedSubviews.compactMap { $0 as? NSButton } ?? []
+ let stopItem = buttons.first
+ let pauseItem = buttons.last
+ check(controls?.orientation == .horizontal && buttons.map(\.title) == ["Stop", "Pause"] &&
+ buttons.allSatisfy { $0.imagePosition == .noImage && $0.bezelStyle == .rounded },
+ "launch shows Stop and Pause as text push buttons in one horizontal menu row")
+ let menuItems = status?.menu?.items ?? []
+ if let loginIndex = menuItems.firstIndex(where: { $0.title.hasPrefix("Launch at Login") }),
+ let syncIndex = menuItems.firstIndex(where: { $0.title == "Sync with Screen Saver" }) {
+ check(syncIndex == loginIndex + 1 && loginIndex > 0 && menuItems[loginIndex - 1].isSeparatorItem &&
+ syncIndex + 1 < menuItems.count && menuItems[syncIndex + 1].isSeparatorItem,
+ "Sync with Screen Saver sits below Launch at Login between dividers")
+ } else {
+ check(false, "menu contains login and screen saver sync controls")
+ }
check(stored("windows", in: host, as: [DesktopWindow].self)?.isEmpty == true &&
ScreenSyncCoordinator.shared.registeredCount == 0 &&
desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg",
"launch returns before creating views, registering playback, or acquiring desktops")
- check(eventually { desktopWindowCount() == NSScreen.screens.count }, "queued launch starts playback on the main run loop")
+ // AppKit can spend over two seconds drawing the previous fixture before servicing queued startup on CI.
+ let started = eventually(10) { desktopWindowCount() == NSScreen.screens.count }
+ check(started, "queued launch starts playback on the main run loop")
+ guard started else { return }
check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "host launch applies black desktop images")
check(desktopWindowCount() == NSScreen.screens.count, "app creates one desktop window per display")
check(ScreenSyncCoordinator.shared.registeredCount == NSScreen.screens.count, "app starts every display as one coordinated session")
@@ -28,6 +46,29 @@ func wallpaperAppTests() {
let originalIDs = Set(originalWindows.map(ObjectIdentifier.init))
let workspace = NSWorkspace.shared.notificationCenter
+ wait(0.3)
+ check(NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil), "Pause menu action is connected")
+ check(pauseItem?.title == "Resume" && stopItem?.title == "Stop" && pauseItem?.isEnabled == true,
+ "paused menu offers Resume beside Stop")
+ check(originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false },
+ "Pause freezes every display without hiding its wallpaper")
+ if let source = originalWindows.first?.contentView {
+ let frozen = pixels(of: source)
+ wait(0.3)
+ check(frozen != nil && pixels(of: source) == frozen, "paused wallpaper retains the same pixels")
+ }
+ workspace.post(name: NSWorkspace.willSleepNotification, object: nil)
+ check(desktopWindowCount() == 0, "sleep hides an already manually paused wallpaper")
+ workspace.post(name: NSWorkspace.didWakeNotification, object: nil)
+ check(originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false },
+ "wake reveals the frozen wallpaper without clearing manual pause")
+ workspace.post(name: NSWorkspace.willSleepNotification, object: nil)
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check(desktopWindowCount() == 0 && originalWindows.allSatisfy { ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false },
+ "Resume cannot override system sleep")
+ workspace.post(name: NSWorkspace.didWakeNotification, object: nil)
+ check(pauseItem?.title == "Pause" && originalWindows.allSatisfy { $0.isVisible && ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true },
+ "Resume continues the original views after the system suspension ends")
workspace.post(name: NSWorkspace.willSleepNotification, object: nil)
check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "temporary sleep retains black desktops and backups")
check(desktopWindowCount() == 0 && ScreenSyncCoordinator.shared.registeredCount == originalWindows.count,
@@ -48,6 +89,8 @@ func wallpaperAppTests() {
if let source = originalWindows.first?.contentView as? MatrixScreenSaverView,
let screen = originalWindows.first?.screen,
let displayID = screen.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? UInt32 {
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check(!source.isAnimating, "wallpaper is paused before saver discovery")
var reader: WallpaperFrameReader? = WallpaperFrameReader()
_ = reader?.frame(displayID: displayID, pointSize: source.bounds.size)
var first: SharedWallpaperFrame.Frame?
@@ -56,7 +99,8 @@ func wallpaperAppTests() {
wait(0.1)
first = reader?.frame(displayID: displayID, pointSize: source.bounds.size)
} while first == nil && Date() < deadline
- check(first != nil, "host discovery publishes a matching display without restarting its renderer")
+ check(first != nil && source.isAnimating && pauseItem?.title == "Resume",
+ "saver discovery advances the paused scene without clearing manual pause")
let distributed = DistributedNotificationCenter.default()
distributed.postNotificationName(Notification.Name("com.apple.screensaver.willstart"), object: nil,
userInfo: nil, deliverImmediately: true)
@@ -67,6 +111,10 @@ func wallpaperAppTests() {
let next = reader?.frame(displayID: displayID, pointSize: source.bounds.size)
check(next != nil && next!.sequence > (first?.sequence ?? 0),
"hidden wallpaper publishes advancing frames for the saver")
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check(source.isAnimating && pauseItem?.title == "Resume" && desktopWindowCount() == 0,
+ "changing manual pause during saver playback preserves the active shared scene")
let sharedURL = source.shareWallpaperFrames()!
NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil)
check(!FileManager.default.fileExists(atPath: sharedURL.path) && !source.isAnimating,
@@ -84,6 +132,22 @@ func wallpaperAppTests() {
}, "re-enabling sync reconnects through normal frame discovery")
check(Set(NSApp.windows.filter { $0 is DesktopWindow }.map(ObjectIdentifier.init)).isSuperset(of: originalIDs),
"sync reconnect retains the existing wallpaper views")
+ reader = nil
+ distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil,
+ userInfo: nil, deliverImmediately: true)
+ wait(0.2)
+ check(!source.isAnimating && pauseItem?.title == "Resume" &&
+ Set(originalWindows.filter(\.isVisible).map(ObjectIdentifier.init)) == originalIDs,
+ "saver exit immediately reveals the preserved paused wallpaper before its lease expires")
+ let frozen = pixels(of: source)
+ wait(0.3)
+ check(frozen != nil && pixels(of: source) == frozen, "returned wallpaper stays frozen at the advanced scene")
+ distributed.postNotificationName(Notification.Name("com.apple.screensaver.willstart"), object: nil,
+ userInfo: nil, deliverImmediately: true)
+ reader = WallpaperFrameReader()
+ check(eventually(5) {
+ reader?.frame(displayID: displayID, pointSize: source.bounds.size) != nil && source.isAnimating
+ }, "a second saver session resumes sharing while manual pause remains set")
workspace.post(name: NSWorkspace.willSleepNotification, object: nil)
NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil)
NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil)
@@ -125,12 +189,19 @@ func wallpaperAppTests() {
distributed.postNotificationName(Notification.Name("com.apple.screensaver.didstop"), object: nil,
userInfo: nil, deliverImmediately: true)
wait(0.2)
- check(source.isAnimating && Set(NSApp.windows.filter { $0 is DesktopWindow && $0.isVisible }.map(ObjectIdentifier.init)) == originalIDs,
- "saver exit reveals the same wallpaper views without replaying the intro")
+ check(!source.isAnimating && pauseItem?.title == "Resume" &&
+ Set(NSApp.windows.filter { $0 is DesktopWindow && $0.isVisible }.map(ObjectIdentifier.init)) == originalIDs,
+ "saver exit after lease expiry keeps the same wallpaper views paused")
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check(source.isAnimating, "manual Resume still works after synced saver playback")
}
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
workspace.post(name: NSWorkspace.willSleepNotification, object: nil)
check(NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil), "Stop menu action is connected")
+ check(stopItem?.title == "Start" && pauseItem?.title == "Pause" && pauseItem?.isEnabled == false,
+ "Stop clears manual pause and disables Pause beside Start")
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
check(desktop.images["first"]?.url.path == "/original-a.jpg" && desktop.images["second"]?.url.path == "/original-b.jpg",
"Stop restores desktop images even during suspension")
NSApp.sendAction(NSSelectorFromString("toggleScreenSaverSync:"), to: host, from: nil)
@@ -160,6 +231,9 @@ func wallpaperAppTests() {
NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: host, from: nil)
check(desktopWindowCount() == NSScreen.screens.count, "Start recreates wallpaper on all displays")
+ check(pauseItem?.isEnabled == true && (stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy {
+ ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true
+ }, "Start runs normally after stopping a paused session")
check(desktop.images.values.allSatisfy { $0.url == desktopController.blackImageURL }, "Start reapplies black desktops")
NotificationCenter.default.post(name: NSApplication.didChangeScreenParametersNotification, object: nil)
check(desktopWindowCount() == NSScreen.screens.count && ScreenSyncCoordinator.shared.registeredCount == NSScreen.screens.count,
@@ -249,6 +323,21 @@ private func wallpaperDeferredLaunchTests() {
} catch {
check(false, "deferred recovery fixture: \(error)")
}
+ do {
+ let desktop = DesktopWallpaperFixture()
+ let host = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller())
+ host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
+ defer { host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) }
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check(eventually { desktopWindowCount() == NSScreen.screens.count }, "Pause during queued launch still creates desktop surfaces")
+ check((stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy {
+ ($0.contentView as? MatrixScreenSaverView)?.isAnimating == false
+ }, "queued startup preserves an immediate manual pause")
+ NSApp.sendAction(NSSelectorFromString("togglePause:"), to: host, from: nil)
+ check((stored("windows", in: host, as: [DesktopWindow].self) ?? []).allSatisfy {
+ ($0.contentView as? MatrixScreenSaverView)?.isAnimating == true
+ }, "Resume starts the session paused during launch")
+ }
for action in ["Stop", "Quit", "Stop then Start"] {
let desktop = DesktopWallpaperFixture()
var writes = 0
@@ -373,7 +462,13 @@ private func wallpaperTerminationRecoveryTests() {
let controller = desktop.controller()
let host = WallpaperApp(defaults: defaults, desktopWallpaper: controller)
host.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
- check(eventually { desktopWindowCount() == NSScreen.screens.count }, "recovery fixture awaits launch playback")
+ // Match the background-failure fixture's allowance for queued AppKit startup on CI.
+ let started = eventually(10) { desktopWindowCount() == NSScreen.screens.count }
+ check(started, "recovery fixture awaits launch playback")
+ guard started else {
+ host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification))
+ return
+ }
desktop.unreadable = ["first"]
host.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification))
check(NSApp.modalWindow == nil && desktopWindowCount() == 0,
@@ -385,12 +480,14 @@ private func wallpaperTerminationRecoveryTests() {
desktop.images["first"] = .init(url: URL(fileURLWithPath: "/new-after-failed-quit.jpg"), options: [:])
let relaunched = WallpaperApp(defaults: defaults, desktopWallpaper: desktop.controller())
relaunched.applicationDidFinishLaunching(Notification(name: NSApplication.didFinishLaunchingNotification))
- check(eventually { desktopWindowCount() == NSScreen.screens.count }, "relaunch awaits recovery and playback")
+ defer { relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification)) }
+ let restarted = eventually(10) { desktopWindowCount() == NSScreen.screens.count }
+ check(restarted, "relaunch awaits recovery and playback")
+ guard restarted else { return }
check(desktop.images.values.allSatisfy { $0.url == controller.blackImageURL }, "relaunch recovers stale entries and starts black")
NSApp.sendAction(NSSelectorFromString("togglePlayback:"), to: relaunched, from: nil)
check(desktop.images["first"]?.url.path == "/new-after-failed-quit.jpg",
"relaunch captures a newer user choice instead of restoring a stale original")
- relaunched.applicationWillTerminate(Notification(name: NSApplication.willTerminateNotification))
}
/// Exercises installation discovery and persistent menu state without touching real installations.
diff --git a/Tests/Signing/signing_test.py b/Tests/Signing/signing_test.py
index f00571a..e4889ab 100644
--- a/Tests/Signing/signing_test.py
+++ b/Tests/Signing/signing_test.py
@@ -77,9 +77,12 @@ def prepare_notary(self, status="Accepted"):
response = self.directory / "response.plist"
response.write_bytes(plistlib.dumps({"status": status, "id": "test-id"}))
self.env["NOTARY_RESPONSE"] = str(response)
- self.stub("ditto", 'printf "%s\\n" "$*" >> "$COMMAND_LOG"\n')
+ self.stub("ditto", '''printf '%s\\n' "$*" >> "$COMMAND_LOG"
+if [[ "$1" == -c ]]; then [[ "$#" == 5 ]]; else [[ "$#" == 2 ]]; fi
+''')
self.stub("xcrun", '''printf '%s\\n' "$*" >> "$COMMAND_LOG"
if [[ "$1 $2" == 'notarytool submit' ]]; then cat "$NOTARY_RESPONSE"; fi
+if [[ "$1" == stapler ]]; then [[ "$#" == 3 ]]; fi
if [[ "$1 $2" == 'stapler staple' ]]; then exit "${STAPLE_FAILURE:-0}"; fi
''')
@@ -89,7 +92,7 @@ def test_accepted_submission_staples_and_validates_both_bundles(self):
self.assertEqual(0, result.returncode, result.stderr)
commands = self.log.read_text()
self.assertIn("--keychain-profile test-profile --wait", commands)
- for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"):
+ for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"):
self.assertIn(f"stapler staple {ROOT}/build/{bundle}", commands)
self.assertIn(f"stapler validate {ROOT}/build/{bundle}", commands)
@@ -112,7 +115,7 @@ def test_release_notarizes_before_staging_and_cleans_up_on_failure(self):
self.assertNotIn("secrets.DEVELOPER_ID", prepare)
self.assertIn("SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}", publish)
self.assertIn("APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}", publish)
- for bundle in ("MatrixScreenSaver.saver", "MatrixWallpaper.app"):
+ for bundle in ("MatrixScreenSaver.saver", "Matrix Wallpaper.app"):
sign_command = f'bash Scripts/sign-bundle.sh "build/{bundle}"'
self.assertIn(sign_command, publish)
self.assertLess(publish.index("./build-wallpaper.sh"), publish.index(sign_command))
diff --git a/build-wallpaper.sh b/build-wallpaper.sh
index 4603728..24f6489 100755
--- a/build-wallpaper.sh
+++ b/build-wallpaper.sh
@@ -4,7 +4,7 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_DIR="$ROOT_DIR/Sources/MatrixScreenSaver"
WALLPAPER_DIR="$ROOT_DIR/Sources/MatrixWallpaper"
-BUNDLE_DIR="$ROOT_DIR/build/MatrixWallpaper.app"
+BUNDLE_DIR="$ROOT_DIR/build/Matrix Wallpaper.app"
if [[ ! -s "$ROOT_DIR/VERSION" ]]; then
echo "VERSION file is missing or empty." >&2
@@ -36,6 +36,8 @@ swiftc -O -module-name MatrixWallpaper \
-o "$BUNDLE_DIR/Contents/MacOS/MatrixWallpaper"
cp "$ROOT_DIR/Resources/Wallpaper-Info.plist" "$BUNDLE_DIR/Contents/Info.plist"
+mkdir -p "$BUNDLE_DIR/Contents/Resources/Fonts"
+cp "$ROOT_DIR/Resources/Fonts/"* "$BUNDLE_DIR/Contents/Resources/Fonts/"
cp "$ROOT_DIR/Resources/WallpaperIcon.icns" "$ROOT_DIR/Resources/MenuBarIcon.pdf" "$BUNDLE_DIR/Contents/Resources/"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUNDLE_VERSION" "$BUNDLE_DIR/Contents/Info.plist"
diff --git a/build.sh b/build.sh
index 7ed2ca3..4db86f1 100755
--- a/build.sh
+++ b/build.sh
@@ -46,6 +46,7 @@ swiftc \
"$SOURCE_DIR/MatrixScreenSaverView.swift"
cp "$INPUT_RESOURCES_DIR/Info.plist" "$CONTENTS_DIR/Info.plist"
+cp -R "$INPUT_RESOURCES_DIR/Fonts" "$RESOURCES_DIR/Fonts"
for f in Preview.png "Preview@2x.png"; do
if [[ -f "$INPUT_RESOURCES_DIR/$f" ]]; then
cp "$INPUT_RESOURCES_DIR/$f" "$RESOURCES_DIR/$f"
diff --git a/install-wallpaper.sh b/install-wallpaper.sh
index a65b3c9..b974ebe 100755
--- a/install-wallpaper.sh
+++ b/install-wallpaper.sh
@@ -2,9 +2,10 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
-SOURCE_BUNDLE="${1:-$ROOT_DIR/build/MatrixWallpaper.app}"
+SOURCE_BUNDLE="${1:-$ROOT_DIR/build/Matrix Wallpaper.app}"
INSTALL_DIR="$HOME/Applications"
-INSTALL_BUNDLE="$INSTALL_DIR/MatrixWallpaper.app"
+INSTALL_BUNDLE="$INSTALL_DIR/Matrix Wallpaper.app"
+LEGACY_BUNDLE="$INSTALL_DIR/MatrixWallpaper.app"
if ! osascript -l JavaScript <<'JXA'
ObjC.import('AppKit');
@@ -51,6 +52,11 @@ if ! verify_bundle "$SOURCE_BUNDLE"; then
exit 1
fi
+if [[ -d "$INSTALL_BUNDLE" ]] && ! verify_bundle "$INSTALL_BUNDLE"; then
+ echo "Refusing to replace an unrecognized app: $INSTALL_BUNDLE" >&2
+ exit 1
+fi
+
mkdir -p "$INSTALL_DIR"
STAGING="$(mktemp -d "$INSTALL_DIR/.MatrixWallpaper-install.XXXXXX")"
@@ -62,6 +68,12 @@ cleanup() {
return 1
fi
fi
+ if [[ "$result" -ne 0 && -d "$STAGING/legacy.app" && ! -e "$LEGACY_BUNDLE" ]]; then
+ if ! mv "$STAGING/legacy.app" "$LEGACY_BUNDLE"; then
+ echo "Restore failed. Your previous app is preserved at $STAGING/legacy.app" >&2
+ return 1
+ fi
+ fi
rm -rf "$STAGING"
return "$result"
}
@@ -69,10 +81,10 @@ trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
-ditto "$SOURCE_BUNDLE" "$STAGING/MatrixWallpaper.app"
-verify_bundle "$STAGING/MatrixWallpaper.app"
+ditto "$SOURCE_BUNDLE" "$STAGING/Matrix Wallpaper.app"
+verify_bundle "$STAGING/Matrix Wallpaper.app"
SOURCE_HASH="$(shasum -a 256 "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')"
-COPIED_HASH="$(shasum -a 256 "$STAGING/MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')"
+COPIED_HASH="$(shasum -a 256 "$STAGING/Matrix Wallpaper.app/Contents/MacOS/MatrixWallpaper" | awk '{print $1}')"
if [[ "$SOURCE_HASH" != "$COPIED_HASH" ]]; then
echo "Copied executable does not match the source; installation cancelled." >&2
exit 1
@@ -81,6 +93,10 @@ fi
if [[ -d "$INSTALL_BUNDLE" ]]; then
mv "$INSTALL_BUNDLE" "$STAGING/previous.app"
fi
-mv "$STAGING/MatrixWallpaper.app" "$INSTALL_BUNDLE"
+# Keep the old bundle for rollback until promotion succeeds; ignore unrelated bundles and symlinks.
+if [[ ! -L "$LEGACY_BUNDLE" && -d "$LEGACY_BUNDLE" ]] && verify_bundle "$LEGACY_BUNDLE" >/dev/null 2>&1; then
+ mv "$LEGACY_BUNDLE" "$STAGING/legacy.app"
+fi
+mv "$STAGING/Matrix Wallpaper.app" "$INSTALL_BUNDLE"
echo "Installed $INSTALL_BUNDLE"
echo "Double-click it in Finder, or run: open \"$INSTALL_BUNDLE\""
diff --git a/tests.sh b/tests.sh
index 196e6a2..3a04d22 100755
--- a/tests.sh
+++ b/tests.sh
@@ -4,7 +4,13 @@ set -euo pipefail
ROOT="$(cd "$(dirname "$0")" && pwd)"
SRC="$ROOT/Sources/MatrixScreenSaver"
TESTS="$ROOT/Tests/MatrixScreenSaverTests"
-TEST_BIN=/tmp/matrix_screensaver_tests
+# A bundle exercises the same font-resource lookup used by both shipping products.
+TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-screensaver-tests.XXXXXX")"
+trap 'rm -rf "$TEST_ROOT"' EXIT
+TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents"
+TEST_BIN="$TEST_CONTENTS/MacOS/matrix_screensaver_tests"
+mkdir -p "$TEST_CONTENTS/MacOS" "$TEST_CONTENTS/Resources"
+cp -R "$ROOT/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts"
swiftc \
-framework Foundation \
diff --git a/wallpaper-install-tests.sh b/wallpaper-install-tests.sh
index cae7980..dadfb09 100755
--- a/wallpaper-install-tests.sh
+++ b/wallpaper-install-tests.sh
@@ -2,7 +2,7 @@
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
-SOURCE_BUNDLE="${1:-$ROOT_DIR/build/MatrixWallpaper.app}"
+SOURCE_BUNDLE="${1:-$ROOT_DIR/build/Matrix Wallpaper.app}"
if [[ ! -d "$SOURCE_BUNDLE" ]]; then
echo "Build the app with ./build-wallpaper.sh before running installer checks." >&2
exit 1
@@ -10,6 +10,9 @@ fi
TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-wallpaper-install-tests.XXXXXX")"
trap 'rm -rf "$TEST_DIR"' EXIT
export HOME="$TEST_DIR/home"
+INSTALL_BUNDLE="$HOME/Applications/Matrix Wallpaper.app"
+LEGACY_BUNDLE="$HOME/Applications/MatrixWallpaper.app"
+EXPECTED_BUNDLE="$INSTALL_BUNDLE"
mkdir -p "$HOME" "$TEST_DIR/bin"
export PATH="$TEST_DIR/bin:$PATH"
@@ -31,7 +34,7 @@ fi
SCRIPT
cat > "$TEST_DIR/bin/mv" <<'SCRIPT'
#!/bin/bash
-if [[ "${FAIL_PROMOTION:-0}" == 1 && "$1" == */.MatrixWallpaper-install.*/MatrixWallpaper.app ]]; then
+if [[ "${FAIL_PROMOTION:-0}" == 1 && "$1" == */.MatrixWallpaper-install.*/"Matrix Wallpaper.app" ]]; then
exit 1
fi
/bin/mv "$@"
@@ -44,17 +47,17 @@ expect_failure() {
echo "not ok - $1" >&2
exit 1
fi
- [[ "$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")" == "$OLD_INODE" ]]
- codesign --verify --deep --strict "$HOME/Applications/MatrixWallpaper.app"
+ [[ "$(stat -f %i "$EXPECTED_BUNDLE")" == "$OLD_INODE" ]]
+ codesign --verify --deep --strict "$EXPECTED_BUNDLE"
echo "ok - $1 preserves the working installation"
}
install_app
[[ -f "$QUIT_LOG" ]]
-codesign --verify --deep --strict "$HOME/Applications/MatrixWallpaper.app"
-cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$HOME/Applications/MatrixWallpaper.app/Contents/MacOS/MatrixWallpaper"
+codesign --verify --deep --strict "$INSTALL_BUNDLE"
+cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$INSTALL_BUNDLE/Contents/MacOS/MatrixWallpaper"
echo "ok - installs a verified app into user Applications"
-OLD_INODE="$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")"
+OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")"
mkdir -p "$HOME/Library/Preferences"
printf 'unchanged preferences' > "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist"
export FAIL_COPY=1
@@ -70,7 +73,61 @@ export SIMULATE_QUIT_FAILURE=1
expect_failure "app refusing to quit"
unset SIMULATE_QUIT_FAILURE
install_app
-[[ "$(stat -f %i "$HOME/Applications/MatrixWallpaper.app")" != "$OLD_INODE" ]]
+[[ "$(stat -f %i "$INSTALL_BUNDLE")" != "$OLD_INODE" ]]
[[ "$(cat "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist")" == 'unchanged preferences' ]]
[[ -z "$(find "$HOME/Applications" -maxdepth 1 -name '.MatrixWallpaper-install.*' -print)" ]]
echo "ok - reinstall replaces the old bundle and cleans staging"
+
+# An old-name-only installation must survive failures at each replacement stage.
+/bin/mv "$INSTALL_BUNDLE" "$LEGACY_BUNDLE"
+EXPECTED_BUNDLE="$LEGACY_BUNDLE"
+OLD_INODE="$(stat -f %i "$LEGACY_BUNDLE")"
+for failure in FAIL_COPY CORRUPT_COPY FAIL_PROMOTION; do
+ export "$failure=1"
+ expect_failure "rename migration $failure"
+ unset "$failure"
+ [[ ! -e "$INSTALL_BUNDLE" ]]
+done
+install_app
+[[ ! -e "$LEGACY_BUNDLE" ]]
+codesign --verify --deep --strict "$INSTALL_BUNDLE"
+cmp "$SOURCE_BUNDLE/Contents/MacOS/MatrixWallpaper" "$INSTALL_BUNDLE/Contents/MacOS/MatrixWallpaper"
+echo "ok - migrates the old filename to Matrix Wallpaper.app"
+
+# Both prior copies are recoverable when a user already has both filenames.
+/usr/bin/ditto "$SOURCE_BUNDLE" "$LEGACY_BUNDLE"
+LEGACY_INODE="$(stat -f %i "$LEGACY_BUNDLE")"
+EXPECTED_BUNDLE="$INSTALL_BUNDLE"
+OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")"
+export FAIL_PROMOTION=1
+expect_failure "promotion with both filenames"
+unset FAIL_PROMOTION
+[[ "$(stat -f %i "$LEGACY_BUNDLE")" == "$LEGACY_INODE" ]]
+codesign --verify --deep --strict "$LEGACY_BUNDLE"
+install_app
+[[ ! -e "$LEGACY_BUNDLE" ]]
+echo "ok - rolls back both filenames and removes the legacy copy only after success"
+
+/usr/bin/ditto "$SOURCE_BUNDLE" "$LEGACY_BUNDLE"
+/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier dev.example.Unrelated' "$LEGACY_BUNDLE/Contents/Info.plist"
+codesign --force --deep --sign - "$LEGACY_BUNDLE"
+LEGACY_INODE="$(stat -f %i "$LEGACY_BUNDLE")"
+install_app
+[[ "$(stat -f %i "$LEGACY_BUNDLE")" == "$LEGACY_INODE" ]]
+[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$LEGACY_BUNDLE/Contents/Info.plist")" == dev.example.Unrelated ]]
+codesign --verify --deep --strict "$LEGACY_BUNDLE"
+echo "ok - preserves an unrelated signed bundle at the old filename"
+
+rm -rf "$LEGACY_BUNDLE"
+ln -s "$INSTALL_BUNDLE" "$LEGACY_BUNDLE"
+install_app
+[[ -L "$LEGACY_BUNDLE" && "$(readlink "$LEGACY_BUNDLE")" == "$INSTALL_BUNDLE" ]]
+echo "ok - leaves an old-name symlink untouched"
+
+/usr/libexec/PlistBuddy -c 'Set :CFBundleIdentifier dev.example.Unrelated' "$INSTALL_BUNDLE/Contents/Info.plist"
+codesign --force --deep --sign - "$INSTALL_BUNDLE"
+OLD_INODE="$(stat -f %i "$INSTALL_BUNDLE")"
+expect_failure "unrelated app at the new filename"
+[[ "$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$INSTALL_BUNDLE/Contents/Info.plist")" == dev.example.Unrelated ]]
+[[ "$(cat "$HOME/Library/Preferences/dev.patsch.MatrixScreenSaver.plist")" == 'unchanged preferences' ]]
+[[ -z "$(find "$HOME/Applications" -maxdepth 1 -name '.MatrixWallpaper-install.*' -print)" ]]
diff --git a/wallpaper-tests.sh b/wallpaper-tests.sh
index a55328a..2f833fa 100755
--- a/wallpaper-tests.sh
+++ b/wallpaper-tests.sh
@@ -5,9 +5,14 @@ ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
SOURCE_DIR="$ROOT_DIR/Sources/MatrixScreenSaver"
WALLPAPER_DIR="$ROOT_DIR/Sources/MatrixWallpaper"
TEST_DIR="$ROOT_DIR/Tests/MatrixWallpaperTests"
-TEST_BIN="$(mktemp "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")"
-HOST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/matrix-consumer-tests.XXXXXX")"
-trap 'rm -f "$TEST_BIN" "$HOST_DIR/ScreenSaverEngine"; rmdir "$HOST_DIR"' EXIT
+# Keep bundled font lookup identical in the app tests and the consumer-host copy.
+TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/matrix-wallpaper-tests.XXXXXX")"
+trap 'rm -rf "$TEST_ROOT"' EXIT
+TEST_CONTENTS="$TEST_ROOT/Tests.app/Contents"
+HOST_DIR="$TEST_CONTENTS/MacOS"
+TEST_BIN="$HOST_DIR/matrix-wallpaper-tests"
+mkdir -p "$HOST_DIR" "$TEST_CONTENTS/Resources"
+cp -R "$ROOT_DIR/Resources/Fonts" "$TEST_CONTENTS/Resources/Fonts"
OPTIMIZATION="-Onone"
if [[ "${1:-}" == "--gpu-composition-benchmark" || "${1:-}" == "--composition-benchmark" ]]; then