diff --git a/.audit/omacy-impl.tsv b/.audit/omacy-impl.tsv
new file mode 100644
index 0000000..5b76523
--- /dev/null
+++ b/.audit/omacy-impl.tsv
@@ -0,0 +1,15 @@
+ts phase decision why evidence result
+2026-08-27T14:54:03Z frame lock done-predicate: engine FFI+parity tests green on Linux; Swift host+appex sources match architecture.md; canary idle/System Settings remains a Mac gate Linux VM cannot compile Swift or run ScreenSaverEngine; prove the engine here and ship complete macOS sources docs/architecture.md:298 docs/ffi.md docs/parity.md framed
+2026-08-27T15:09:47Z harness vendor ttfx at v0.3.2 pin and copy Omarchy wordmark scaffold before features; pin is the recorded SHA not floating master vendor/ttfx/PIN assets/branding/screensaver.txt copied
+2026-08-27T16:22:38Z impl snapshot fill_grid at frame() emit unstable rumble restores coords after ANSI; live pack desynced Metal from oracle vendor/ttfx/src/engine/terminal.rs store_snapshot; tests/parity.rs 37x2x2x180 green pass
+2026-08-27T16:22:38Z impl ascii goldens + Fairfax HD + host/appex sources parity.md conversion matrix and architecture host+appex assets/fixtures/conversion apps/Omacy docs/macos-gates.md engine-proven; Mac idle unverified
+2026-08-27T16:50:00Z impl close remaining architecture gaps atomic store, lifecycle, occupancy/atlas, dead recover, copy, panic+budget tests cargo test -p omacy-engine; cargo test --manifest-path vendor/ttfx/Cargo.toml; cargo test -p omacy-engine --release --test budget engine pass; Mac idle unverified
+2026-08-27T16:46:50Z impl close remaining architecture gaps atomic store, lifecycle, occupancy/atlas, dead recover, copy, panic+budget tests docs/architecture.md testing table + host/appex sources in-progress
+2026-08-27T17:01:46Z impl canary thumbnails, SVG href reject, session contract tests architecture thumbnail + FFI wait/pending/re-entrant + no file SVG cargo test -p omacy-engine pass
+2026-08-27T17:06:12Z impl appex per-display loadView + duplicate pluginkit warning Settings preview 30Hz and multi-display need the real frame; PlugInKit sticky if two locations apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift PluginManager.swift in-progress
+2026-08-27T17:15:04Z ci add macos-15 xcodebuild of host+appex Linux cannot compile Swift; a GitHub Mac runner can prove the Aerial-path targets link .github/workflows/macos.yml in-progress
+2026-08-27T17:37:02Z ci macos-26 xcodebuild + renderer NSObject + cargo PATH PaperSaver 0.2.0 needs Swift 6.2; macos-15 was 6.1; Xcode scripts drop ~/.cargo/bin; displayLink needs NSObject .github/workflows/macos.yml apps/Omacy/scripts/build-engine.sh OmacyRenderer.swift in-progress
+2026-08-27T17:49:00Z ci one rust build phase (appex) macos-26 failed: both targets wrote libomacy_engine.a; host already depends on appex apps/Omacy/Omacy.xcodeproj/project.pbxproj in-progress
+2026-08-27T18:11:00Z ci fix displayLink non-optional + omacy_status log macos-26 Swift: guard let on CADisplayLink; Int() on C enum apps/Omacy/Shared/OmacyRenderer.swift in-progress
+2026-08-27T18:47:00Z ci host+appex BUILD SUCCEEDED on Xcode 26.6 deinit no longer calls MainActor stop; PreviewView is a typealias; one -lomacy_engine link local xcodebuild Release arm64; Omacy.app/Contents/PlugIns/OmacyScreensaver.appex pass
+2026-08-27T20:10:30Z impl stop Metal drawable invalidation on layout CAMetalLayer was view.layer; layout rewrote drawableSize + rebuilt atlas every pass; busy ring presented cleared frames preview video 45% black frames; Peter confirmed host preview continuous pass
diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml
new file mode 100644
index 0000000..f813fc7
--- /dev/null
+++ b/.github/workflows/engine.yml
@@ -0,0 +1,21 @@
+name: engine
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: 1.88.0
+ - name: Engine tests
+ run: cargo test -p omacy-engine
+ - name: Vendored ttfx tests
+ run: cargo test --manifest-path vendor/ttfx/Cargo.toml
diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml
new file mode 100644
index 0000000..107ed37
--- /dev/null
+++ b/.github/workflows/macos.yml
@@ -0,0 +1,46 @@
+name: macos
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ host:
+ # PaperSaver 0.2.0 is swift-tools-version 6.2. macos-15 ships Xcode 16 / Swift 6.1.
+ # Deployment target stays 15.0; this runner is only the compile host (Peter develops on Tahoe).
+ runs-on: macos-26
+ steps:
+ - uses: actions/checkout@v4
+ - uses: dtolnay/rust-toolchain@master
+ with:
+ toolchain: 1.88.0
+ targets: aarch64-apple-darwin
+ - name: Show compilers
+ run: |
+ xcodebuild -version
+ swift --version
+ rustc --version
+ cargo --version
+ - name: Build host app and screensaver appex
+ working-directory: apps/Omacy
+ run: |
+ export PATH="${CARGO_HOME:-$HOME/.cargo}/bin:$PATH"
+ xcodebuild \
+ -project Omacy.xcodeproj \
+ -scheme Omacy \
+ -configuration Release \
+ -destination 'generic/platform=macOS' \
+ -skipMacroValidation \
+ -skipPackagePluginValidation \
+ ARCHS=arm64 \
+ ONLY_ACTIVE_ARCH=YES \
+ EXCLUDED_ARCHS=x86_64 \
+ CODE_SIGN_IDENTITY="-" \
+ CODE_SIGNING_ALLOWED=NO \
+ CODE_SIGNING_REQUIRED=NO \
+ AD_HOC_CODE_SIGNING_ALLOWED=YES \
+ CODE_SIGN_STYLE=Manual \
+ DEVELOPMENT_TEAM="" \
+ ENABLE_HARDENED_RUNTIME=NO \
+ build
diff --git a/.gitignore b/.gitignore
index cf8de4a..6e37a84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -57,9 +57,13 @@ Carthage/Build/
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
+fastlane/README.md
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
+fastlane/.env
+*.p8
+build/
# Rust
target/
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..226922e
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,858 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_complete"
+version = "4.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3be2ad0423bdbbb0e25bc89add796f3559706d4a95e1bc98e4d9662a957b6a19"
+dependencies = [
+ "clap",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "color_quant"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "data-url"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "float-cmp"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
+
+[[package]]
+name = "fontconfig-parser"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
+dependencies = [
+ "roxmltree",
+]
+
+[[package]]
+name = "fontdb"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905"
+dependencies = [
+ "fontconfig-parser",
+ "log",
+ "memmap2",
+ "slotmap",
+ "tinyvec",
+ "ttf-parser",
+]
+
+[[package]]
+name = "gif"
+version = "0.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
+dependencies = [
+ "color_quant",
+ "weezl",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+]
+
+[[package]]
+name = "image-webp"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
+dependencies = [
+ "byteorder-lite",
+ "quick-error",
+]
+
+[[package]]
+name = "imagesize"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "kurbo"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "omacy-engine"
+version = "0.1.0"
+dependencies = [
+ "image",
+ "libc",
+ "resvg",
+ "serde",
+ "serde_json",
+ "tiny-skia",
+ "ttfx",
+]
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "pico-args"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "resvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43"
+dependencies = [
+ "gif",
+ "image-webp",
+ "log",
+ "pico-args",
+ "rgb",
+ "svgtypes",
+ "tiny-skia",
+ "usvg",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "rgb"
+version = "0.8.53"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.13.1",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "rustybuzz"
+version = "0.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "core_maths",
+ "log",
+ "smallvec",
+ "ttf-parser",
+ "unicode-bidi-mirroring",
+ "unicode-ccc",
+ "unicode-properties",
+ "unicode-script",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "simplecss"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c"
+dependencies = [
+ "log",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+dependencies = [
+ "float-cmp",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "svgtypes"
+version = "0.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc"
+dependencies = [
+ "kurbo",
+ "siphasher",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
+dependencies = [
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "png 0.17.16",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+dependencies = [
+ "core_maths",
+]
+
+[[package]]
+name = "ttfx"
+version = "0.3.2"
+dependencies = [
+ "clap",
+ "clap_complete",
+ "terminal_size",
+]
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-bidi-mirroring"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
+
+[[package]]
+name = "unicode-ccc"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "unicode-script"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
+
+[[package]]
+name = "unicode-vo"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94"
+
+[[package]]
+name = "usvg"
+version = "0.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef"
+dependencies = [
+ "base64",
+ "data-url",
+ "flate2",
+ "fontdb",
+ "imagesize",
+ "kurbo",
+ "log",
+ "pico-args",
+ "roxmltree",
+ "rustybuzz",
+ "simplecss",
+ "siphasher",
+ "strict-num",
+ "svgtypes",
+ "tiny-skia-path",
+ "unicode-bidi",
+ "unicode-script",
+ "unicode-vo",
+ "xmlwriter",
+]
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "xmlwriter"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zune-core"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
+dependencies = [
+ "zune-core",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..6fdfb24
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,16 @@
+[workspace]
+members = ["crates/omacy-engine"]
+resolver = "2"
+
+[workspace.package]
+edition = "2021"
+license = "MIT"
+repository = "https://github.com/dedene/omacy-screensaver"
+
+[profile.dev]
+panic = "unwind"
+
+[profile.release]
+panic = "unwind"
+lto = true
+codegen-units = 1
diff --git a/Gemfile b/Gemfile
new file mode 100644
index 0000000..7a118b4
--- /dev/null
+++ b/Gemfile
@@ -0,0 +1,3 @@
+source "https://rubygems.org"
+
+gem "fastlane"
diff --git a/Gemfile.lock b/Gemfile.lock
new file mode 100644
index 0000000..ed2be90
--- /dev/null
+++ b/Gemfile.lock
@@ -0,0 +1,364 @@
+GEM
+ remote: https://rubygems.org/
+ specs:
+ CFPropertyList (3.0.8)
+ abbrev (0.1.2)
+ addressable (2.9.0)
+ public_suffix (>= 2.0.2, < 8.0)
+ artifactory (3.0.17)
+ atomos (0.1.3)
+ aws-eventstream (1.4.0)
+ aws-partitions (1.1282.0)
+ aws-sdk-core (3.254.1)
+ aws-eventstream (~> 1, >= 1.3.0)
+ aws-partitions (~> 1, >= 1.992.0)
+ aws-sigv4 (~> 1.9)
+ base64
+ bigdecimal
+ jmespath (~> 1, >= 1.6.1)
+ logger
+ aws-sdk-kms (1.130.0)
+ aws-sdk-core (~> 3, >= 3.254.0)
+ aws-sigv4 (~> 1.5)
+ aws-sdk-s3 (1.229.0)
+ aws-sdk-core (~> 3, >= 3.254.1)
+ aws-sdk-kms (~> 1)
+ aws-sigv4 (~> 1.5)
+ aws-sigv4 (1.12.1)
+ aws-eventstream (~> 1, >= 1.0.2)
+ babosa (1.0.4)
+ base64 (0.3.0)
+ benchmark (0.5.0)
+ bigdecimal (4.1.2)
+ claide (1.1.0)
+ colored (1.2)
+ colored2 (3.1.2)
+ commander (4.6.0)
+ highline (~> 2.0.0)
+ csv (3.3.6)
+ declarative (0.0.20)
+ digest-crc (0.7.0)
+ rake (>= 12.0.0, < 14.0.0)
+ domain_name (0.6.20240107)
+ dotenv (2.8.1)
+ emoji_regex (3.2.3)
+ erb (6.0.7)
+ excon (1.7.0)
+ logger
+ faraday (2.14.3)
+ faraday-net_http (>= 2.0, < 3.5)
+ json
+ logger
+ faraday-cookie_jar (0.0.8)
+ faraday (>= 0.8.0)
+ http-cookie (>= 1.0.0)
+ faraday-follow_redirects (0.5.0)
+ faraday (>= 1, < 3)
+ faraday-multipart (1.2.0)
+ multipart-post (~> 2.0)
+ faraday-net_http (3.4.4)
+ net-http (~> 0.5)
+ faraday-retry (2.4.0)
+ faraday (~> 2.0)
+ fastimage (2.4.1)
+ fastlane (2.238.0)
+ CFPropertyList (>= 2.3, < 5.0.0)
+ abbrev (~> 0.1)
+ addressable (>= 2.9.0, < 3.0.0)
+ artifactory (~> 3.0)
+ aws-sdk-s3 (~> 1.197)
+ babosa (>= 1.0.3, < 2.0.0)
+ base64 (~> 0.2)
+ benchmark (>= 0.1.0)
+ bundler (>= 2.4.0, < 5.0.0)
+ colored (~> 1.2)
+ commander (~> 4.6)
+ csv (~> 3.3)
+ dotenv (>= 2.1.1, < 3.0.0)
+ emoji_regex (>= 0.1, < 4.0)
+ excon (>= 0.71.0, < 2.0.0)
+ faraday (~> 2.7)
+ faraday-cookie_jar (~> 0.0.8)
+ faraday-follow_redirects (~> 0.3)
+ faraday-multipart (~> 1.0)
+ faraday-retry (~> 2.0)
+ fastimage (>= 2.1.0, < 3.0.0)
+ fastlane-sirp (>= 1.1.0)
+ gh_inspector (>= 1.1.2, < 2.0.0)
+ google-apis-androidpublisher_v3 (~> 0.3)
+ google-apis-playcustomapp_v1 (~> 0.1)
+ google-cloud-env (>= 1.6.0, < 2.3.0)
+ google-cloud-storage (~> 1.31)
+ highline (~> 2.0)
+ http-cookie (~> 1.0.5)
+ irb (>= 1.8)
+ json (< 3.0.0)
+ jwt (>= 2.10.3, < 4)
+ logger (>= 1.6, < 2.0)
+ mini_magick (>= 4.9.4, < 5.0.0)
+ multi_json (~> 1.12)
+ multipart-post (>= 2.0.0, < 3.0.0)
+ mutex_m (~> 0.3)
+ naturally (~> 2.2)
+ nkf (~> 0.2)
+ optparse (>= 0.1.1, < 1.0.0)
+ ostruct (>= 0.1.0)
+ plist (>= 3.1.0, < 4.0.0)
+ rubyzip (>= 2.0.0, < 3.0.0)
+ security (= 0.1.5)
+ simctl (~> 1.6.3)
+ terminal-notifier (>= 2.0.0, < 3.0.0)
+ terminal-table (~> 4)
+ tty-screen (>= 0.6.3, < 1.0.0)
+ tty-spinner (>= 0.8.0, < 1.0.0)
+ word_wrap (~> 1.0.0)
+ xcodeproj (>= 1.13.0, < 2.0.0)
+ xcpretty (~> 0.4.1)
+ xcpretty-travis-formatter (>= 0.0.3, < 2.0.0)
+ fastlane-sirp (1.1.0)
+ gh_inspector (1.1.3)
+ google-apis-androidpublisher_v3 (0.107.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-core (1.2.5)
+ addressable (~> 2.9)
+ faraday (~> 2.13)
+ faraday-follow_redirects (~> 0.3)
+ googleauth (~> 1.14)
+ mini_mime (~> 1.1)
+ multi_json (~> 1.11)
+ representable (~> 3.0)
+ retriable (>= 3.1, < 5.0)
+ google-apis-iamcredentials_v1 (0.28.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-playcustomapp_v1 (0.18.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-apis-storage_v1 (0.66.0)
+ google-apis-core (>= 0.15.0, < 2.a)
+ google-cloud-core (1.9.0)
+ google-cloud-env (>= 1.0, < 3.a)
+ google-cloud-errors (~> 1.0)
+ google-cloud-env (2.2.2)
+ base64 (~> 0.2)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-errors (1.7.0)
+ google-cloud-storage (1.62.0)
+ addressable (~> 2.8)
+ digest-crc (~> 0.4)
+ google-apis-core (>= 0.18, < 2)
+ google-apis-iamcredentials_v1 (~> 0.18)
+ google-apis-storage_v1 (>= 0.42)
+ google-cloud-core (~> 1.6)
+ googleauth (~> 1.9)
+ mini_mime (~> 1.0)
+ google-logging-utils (0.2.0)
+ googleauth (1.17.4)
+ faraday (>= 1.0, < 3.a)
+ google-cloud-env (~> 2.2)
+ google-logging-utils (~> 0.1)
+ jwt (>= 1.4, < 4.0)
+ os (>= 0.9, < 2.0)
+ pstore (~> 0.1)
+ signet (>= 0.16, < 2.a)
+ highline (2.0.3)
+ http-cookie (1.0.8)
+ domain_name (~> 0.5)
+ io-console (0.9.2)
+ irb (1.18.0)
+ pp (>= 0.6.0)
+ prism (>= 1.3.0)
+ rdoc (>= 4.0.0)
+ reline (>= 0.4.2)
+ jmespath (1.6.2)
+ json (2.21.2)
+ jwt (3.2.0)
+ base64
+ logger (1.7.0)
+ mini_magick (4.13.2)
+ mini_mime (1.1.5)
+ multi_json (1.21.1)
+ multipart-post (2.4.1)
+ mutex_m (0.3.0)
+ nanaimo (0.4.0)
+ naturally (2.3.0)
+ net-http (0.9.1)
+ uri (>= 0.11.1)
+ nkf (0.3.0)
+ optparse (0.8.1)
+ os (1.1.4)
+ ostruct (0.6.3)
+ plist (3.7.2)
+ pp (0.6.4)
+ prettyprint
+ prettyprint (0.2.0)
+ prism (1.9.0)
+ pstore (0.2.1)
+ public_suffix (7.0.5)
+ rake (13.4.2)
+ rbs (4.2.0)
+ logger
+ prism (>= 1.6.0)
+ tsort
+ rdoc (8.0.0)
+ erb
+ prism (>= 1.6.0)
+ rbs (>= 4.0.0)
+ tsort
+ reline (0.7.0)
+ io-console (~> 0.5)
+ representable (3.2.0)
+ declarative (< 0.1.0)
+ trailblazer-option (>= 0.1.1, < 0.2.0)
+ uber (< 0.2.0)
+ retriable (4.2.0)
+ rexml (3.4.4)
+ rouge (3.28.0)
+ rubyzip (2.4.1)
+ security (0.1.5)
+ signet (0.22.0)
+ addressable (~> 2.8)
+ faraday (>= 0.17.5, < 3.a)
+ jwt (>= 1.5, < 4.0)
+ simctl (1.6.10)
+ CFPropertyList
+ naturally
+ terminal-notifier (2.0.0)
+ terminal-table (4.0.0)
+ unicode-display_width (>= 1.1.1, < 4)
+ trailblazer-option (0.1.2)
+ tsort (0.2.0)
+ tty-cursor (0.7.1)
+ tty-screen (0.8.2)
+ tty-spinner (0.9.3)
+ tty-cursor (~> 0.7)
+ uber (0.1.0)
+ unicode-display_width (3.2.0)
+ unicode-emoji (~> 4.1)
+ unicode-emoji (4.2.0)
+ uri (1.1.1)
+ word_wrap (1.0.0)
+ xcodeproj (1.28.1)
+ CFPropertyList (>= 2.3.3, < 4.0)
+ atomos (~> 0.1.3)
+ base64
+ claide (>= 1.0.2, < 2.0)
+ colored2 (~> 3.1)
+ nanaimo (~> 0.4.0)
+ nkf
+ rexml (>= 3.3.6, < 4.0)
+ xcpretty (0.4.1)
+ rouge (~> 3.28.0)
+ xcpretty-travis-formatter (1.0.1)
+ xcpretty (~> 0.2, >= 0.0.7)
+
+PLATFORMS
+ arm64-darwin-25
+ ruby
+
+DEPENDENCIES
+ fastlane
+
+CHECKSUMS
+ CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261
+ abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242
+ addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af
+ artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263
+ atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f
+ aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b
+ aws-partitions (1.1282.0) sha256=d6c7c4ad2e4f8cd4ca56445bfbb3958fec0deecd959c1e2bcd3a83c9aa308c18
+ aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b
+ aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749
+ aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596
+ aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00
+ babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99
+ base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
+ benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
+ bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
+ claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e
+ colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c
+ colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a
+ commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
+ declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9
+ digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07
+ domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933
+ dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
+ emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b
+ erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
+ excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0
+ faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278
+ faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb
+ faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad
+ faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757
+ faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588
+ faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe
+ fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20
+ fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98
+ fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847
+ gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939
+ google-apis-androidpublisher_v3 (0.107.0) sha256=b8207eecc89e6a9f7ea11aa88a07bccdcae5ca90121330079db4ef4696a2f3ff
+ google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69
+ google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106
+ google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254
+ google-apis-storage_v1 (0.66.0) sha256=fdf6d65d3fc77e7046b3494333a818ece9e2afd763bb161e1a7a9e70cf198351
+ google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4
+ google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7
+ google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3
+ google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330
+ google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b
+ googleauth (1.17.4) sha256=acc2cac2a6011048f149c922cbc6c9675719e165aa5000c7fa3876c480fe9ae3
+ highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479
+ http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6
+ io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
+ irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
+ jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
+ jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7
+ logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
+ mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9
+ mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef
+ multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080
+ multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8
+ mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751
+ nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723
+ naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01
+ net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996
+ nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895
+ optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a
+ os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f
+ ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912
+ plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42
+ pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570
+ prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
+ prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85
+ pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a
+ public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623
+ rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
+ rbs (4.2.0) sha256=51f7b886dcc05bc09e10b901daa6a81829f6adc03101d6ca9ea4aac6103e0674
+ rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469
+ reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+ representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace
+ retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc
+ rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142
+ rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20
+ rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615
+ security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7
+ signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780
+ simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b
+ terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea
+ terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
+ trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3
+ tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+ tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48
+ tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50
+ tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542
+ uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc
+ unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+ unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+ uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
+ word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7
+ xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc
+ xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892
+ xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93
+
+BUNDLED WITH
+ 4.0.14
diff --git a/NOTICE b/NOTICE
index f8e71c4..bffcf0d 100644
--- a/NOTICE
+++ b/NOTICE
@@ -13,3 +13,6 @@ LICENSE and NOTICE; those notices must be kept when vendoring ttfx.
Omarchy’s default screensaver wordmark and the image-to-Braille conversion
rules come from https://github.com/basecamp/omarchy, Copyright (c) 37signals /
Omarchy contributors, MIT License.
+
+The bundled screensaver font is Fairfax HD, Copyright (c) 2017–2020 Kreative
+Software, licensed under the SIL Open Font License 1.1. See assets/fonts/OFL.txt.
diff --git a/README.md b/README.md
index 00ca118..0084d17 100644
--- a/README.md
+++ b/README.md
@@ -4,12 +4,48 @@ A macOS screensaver that plays Omarchy’s ASCII text-effects loop: your logo (o
Rust `ttfx` engine, Swift/Metal view, Aerial-style host app + screensaver extension.
-**Status:** architecture only (revision 7). Spec: [docs/architecture.md](docs/architecture.md), [docs/ffi.md](docs/ffi.md), [docs/parity.md](docs/parity.md).
+**Status:** engine tested (`cargo test -p omacy-engine`). Host `.app` + `com.apple.screensaver` `.appex` compile on Xcode 26.6. Idle / System Settings listing remains a Mac gate: [docs/macos-gates.md](docs/macos-gates.md).
+
+Spec: [docs/architecture.md](docs/architecture.md), [docs/ffi.md](docs/ffi.md), [docs/parity.md](docs/parity.md).
+
+## Engine
+
+```
+cargo test -p omacy-engine
+```
+
+Pins: `vendor/ttfx/PIN` is ttfx v0.3.2 (`7203e354498462064b7c0a89375051f65cf2ce99`). Default art is `assets/branding/screensaver.txt`.
+
+## Mac host
+
+Open `apps/Omacy/Omacy.xcodeproj` on macOS 15+. The Xcode build phase runs `apps/Omacy/scripts/build-engine.sh`, which builds `libomacy_engine.a` for `aarch64-apple-darwin` and links it into the app and the appex.
+
+CI compiles that same scheme on `macos-26` (unsigned; deployment target 15.0). Idle / System Settings listing remains a machine gate: [docs/macos-gates.md](docs/macos-gates.md).
+
+Put `Omacy.app` in `/Applications`, then use the host to register the extension (`pluginkit`) and enable it (PaperSaver). The host will not register from a random path.
+
+Configuration (paste art, PNG/SVG conversion, effect, restore default) lives in the host and writes App Group `group.be.zenjoy.omacy`.
+
+## Signing
+
+Developer ID uses Zenjoy team `25TVW8MSGJ` and the same match repo as Zentty (`zentty-certificates`). Profiles:
+
+- `match Direct be.zenjoy.omacy macos`
+- `match Direct be.zenjoy.omacy.screensaver macos`
+
+```
+bundle exec fastlane mac generate_signing # once, or to renew
+bundle exec fastlane mac sync_signing # readonly install
+bundle exec fastlane mac archive # Developer ID export
+bundle exec fastlane mac notarize_app
+```
+
+Env: copy `fastlane/.env.example` or reuse Zentty’s 1Password item (`Zentty Release Environment`).
## Credits
-The motion is [Terminal Text Effects](https://github.com/ChrisBuilds/terminaltexteffects) by ChrisBuilds, running on [ttfx](https://github.com/omacom-io/ttfx), the Rust port Omarchy ships. Both are MIT. The Linux wrapper this copies is [Omarchy](https://github.com/basecamp/omarchy), also MIT.
+The motion is [Terminal Text Effects](https://github.com/ChrisBuilds/terminaltexteffects) by ChrisBuilds, running on [ttfx](https://github.com/omacom-io/ttfx), the Rust port Omarchy ships. Both are MIT. The Linux wrapper this copies is [Omarchy](https://github.com/basecamp/omarchy), also MIT. The bundled font is Fairfax HD by Kreative Software, SIL OFL.
## License
-MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
+MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE). Font: [assets/fonts/OFL.txt](assets/fonts/OFL.txt).
diff --git a/apps/Omacy/.gitattributes b/apps/Omacy/.gitattributes
new file mode 100644
index 0000000..dfe0770
--- /dev/null
+++ b/apps/Omacy/.gitattributes
@@ -0,0 +1,2 @@
+# Auto detect text files and perform LF normalization
+* text=auto
diff --git a/apps/Omacy/.gitignore b/apps/Omacy/.gitignore
new file mode 100644
index 0000000..d32ab36
--- /dev/null
+++ b/apps/Omacy/.gitignore
@@ -0,0 +1,6 @@
+/.build
+.DS_Store
+DerivedData/
+xcuserdata/
+*.xcuserstate
+*.xcbkptlist
diff --git a/apps/Omacy/BACKGROUND.md b/apps/Omacy/BACKGROUND.md
new file mode 100644
index 0000000..7e0e632
--- /dev/null
+++ b/apps/Omacy/BACKGROUND.md
@@ -0,0 +1,284 @@
+# macOS Appex Screensaver — Technical Background
+
+This document captures what's known about developing screensavers for macOS Sonoma and later using the modern ExtensionKit (`.appex`) format. It is meant as a companion to the sample code in this repository.
+
+---
+
+## 1. Architecture
+
+### `.appex` vs. legacy `.saver`
+
+Modern macOS (Sonoma+) supports screensavers packaged as ExtensionKit `.appex` bundles in addition to the legacy `.saver` plug-in format. Apple's own first-party savers (Hello, Arabesque, Drift, Monterey, Ventura, Shell, Flurry) all use the appex format.
+
+| | `.appex` | `.saver` |
+|---|---|---|
+| **Bundle type** | `CFBundlePackageType = XPC!` | `CFBundlePackageType = BNDL` |
+| **Process model** | Separate sandboxed XPC process | Loaded into `legacyScreenSaver.appex` |
+| **Framework** | `ScreenSaver.framework` + ExtensionKit | `ScreenSaver.framework` |
+| **Min macOS** | 14.0 (Sonoma) | All supported macOS |
+| **Distribution** | Embedded inside a host `.app`, registered via `pluginkit` | Standalone `.saver` file |
+
+### Extension Point
+
+- **Identifier:** `com.apple.screensaver`
+- **Version:** `1.0`
+
+### Process Isolation
+
+Each appex screensaver runs in its own sandboxed process. The framework handles all inter-process communication via XPC; you don't write XPC code yourself. View rendering, input events, and lifecycle messages all flow through the framework transparently.
+
+---
+
+## 2. Class Hierarchy
+
+### Public API (`ScreenSaver.framework`)
+
+| Class | Purpose |
+|-------|---------|
+| `ScreenSaverView` | Your main view. NSView subclass with `animateOneFrame()`, `startAnimation()`, `stopAnimation()`, `draw(_:)`. |
+| `ScreenSaverDefaults` | `UserDefaults` subclass for screensaver preferences. |
+
+### ExtensionKit classes used by appex screensavers
+
+These are still partly underdocumented; their declarations live in `OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h` in this repo. On recent SDKs the symbols are also available without the bridging header.
+
+| Class | Purpose |
+|-------|---------|
+| `ScreenSaverExtension` | Principal class. Set as `NSExtensionPrincipalClass` in Info.plist. |
+| `ScreenSaverViewController` | View controller managing the screensaver view. Set as `ScreenSaverViewControllerClass` in Info.plist. |
+| `ScreenSaverConfigurationViewController` | Base class for the optional configuration sheet. |
+
+---
+
+## 3. Info.plist Configuration
+
+### `NSExtension` dictionary
+
+```xml
+NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.screensaver
+ NSExtensionPointVersion
+ 1.0
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).YourScreenSaverExtension
+
+```
+
+### Root-level screensaver keys
+
+| Key | Type | Description |
+|-----|------|-------------|
+| `ScreenSaverViewControllerClass` | String | Fully-qualified name of the main view controller. |
+| `ScreenSaverConfigurationSheetViewControllerClass` | String | Fully-qualified name of the configuration sheet controller. |
+| `SSEHasConfigureSheet` | Boolean | Whether the screensaver has a configuration UI. |
+| `SSENeedsAnimationTimer` | Boolean | Whether the framework should drive a per-frame animation timer (see §5). |
+
+Use `$(PRODUCT_MODULE_NAME).ClassName` so the module prefix tracks your target name automatically.
+
+---
+
+## 4. Apple's Built-in Screensavers (Reference)
+
+Located in `/System/Library/ExtensionKit/Extensions/`.
+
+| Name | Principal Class | View Controller | Has Config |
+|------|-----------------|-----------------|------------|
+| Arabesque | `Arabesque.ArabesqueExtension` | `ArabesqueViewController` | No |
+| Flurry | `Flurry.FlurryExtension` | `FlurryViewController` | Yes |
+| Drift | `Drift.FlowExtension` | `FlowViewController` | Yes |
+| Hello | `Hello.HelloExtension` | `HelloViewController` | Yes |
+| Monterey | `Monterey.CanyonExtension` | `CanyonViewController` | No |
+| Ventura | `Ventura.PetalExtension` | `PetalViewController` | No |
+| Shell | `Shell.ShellExtension` | `ShellViewController` | No |
+
+**Naming conventions** Apple uses:
+
+- Principal class: `Module.ModuleExtension` (e.g. `Arabesque.ArabesqueExtension`)
+- View controller: `ModuleViewController`
+- View class: `Module.ModuleView`
+
+---
+
+## 5. Rendering: three valid approaches
+
+There is no single "right" way to render an appex screensaver. Pick whichever fits your animation model.
+
+### A. Direct CALayer animation (what this sample uses)
+
+Manipulate the backing CALayer directly — either with `CABasicAnimation` (Aerial's fallback and this sample's `OmacyCanaryAnimator`) or with your own `Timer` updating layer properties. Set `SSENeedsAnimationTimer = false` because the layer or your timer drives frames itself.
+
+```swift
+class YourScreenSaverView: ScreenSaverView {
+ override init?(frame: NSRect, isPreview: Bool) {
+ super.init(frame: frame, isPreview: isPreview)
+ wantsLayer = true
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ guard let layer = self.layer, window != nil else { return }
+ let animation = CABasicAnimation(keyPath: "backgroundColor")
+ animation.fromValue = NSColor.red.cgColor
+ animation.toValue = NSColor.blue.cgColor
+ animation.duration = 2.0
+ animation.autoreverses = true
+ animation.repeatCount = .infinity
+ layer.add(animation, forKey: "demo")
+ }
+}
+```
+
+### B. Traditional `ScreenSaverView` overrides
+
+Override `startAnimation()`, `stopAnimation()`, `animateOneFrame()`, and `draw(_:)`. These **do** get called by the framework when `SSENeedsAnimationTimer = true`; they're the same calls a `.saver` plug-in would receive.
+
+```swift
+class YourScreenSaverView: ScreenSaverView {
+ override init?(frame: NSRect, isPreview: Bool) {
+ super.init(frame: frame, isPreview: isPreview)
+ animationTimeInterval = 1.0 / 30.0
+ }
+
+ override func animateOneFrame() {
+ super.animateOneFrame()
+ // Update model state, then trigger a redraw:
+ setNeedsDisplay(bounds)
+ }
+
+ override func draw(_ rect: NSRect) {
+ NSColor.black.setFill()
+ bounds.fill()
+ // ...your drawing here...
+ }
+}
+```
+
+### C. SwiftUI via `NSHostingView`
+
+You can host SwiftUI inside an appex screensaver by wrapping your SwiftUI root view in `NSHostingView` and adding it as a subview of the `ScreenSaverView`. The [Aerial](https://github.com/AerialScreensaver/Aerial) screensaver uses this pattern for weather, music, and clock overlays painted on top of its video player.
+
+```swift
+import SwiftUI
+
+class YourScreenSaverView: ScreenSaverView {
+ private var hosting: NSHostingView?
+
+ override init?(frame: NSRect, isPreview: Bool) {
+ super.init(frame: frame, isPreview: isPreview)
+ wantsLayer = true
+
+ let host = NSHostingView(rootView: YourSwiftUIView())
+ host.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(host)
+ NSLayoutConstraint.activate([
+ host.leadingAnchor.constraint(equalTo: leadingAnchor),
+ host.trailingAnchor.constraint(equalTo: trailingAnchor),
+ host.topAnchor.constraint(equalTo: topAnchor),
+ host.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
+ hosting = host
+ }
+}
+```
+
+Note that SwiftUI is hosted *inside* the `ScreenSaverView`; it is not the principal class. The principal class must still be a `ScreenSaverExtension` subclass.
+
+### View controller setup
+
+The view controller overrides `loadView()` to instantiate and assign the screensaver view:
+
+```swift
+@objc(YourViewController)
+class YourViewController: ScreenSaverViewController {
+ override func loadView() {
+ let frame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
+ let isPreview = frame.width < 400
+ self.view = YourScreenSaverView(frame: frame, isPreview: isPreview)
+ ?? NSView(frame: frame)
+ }
+}
+```
+
+---
+
+## 6. Registration and Discovery
+
+### Using `pluginkit`
+
+```bash
+# Register an appex
+pluginkit -a /path/to/YourScreensaver.app/Contents/PlugIns/YourScreensaver.appex
+
+# List all registered screensavers
+pluginkit -m -v -p com.apple.screensaver
+
+# Unregister
+pluginkit -r /path/to/YourScreensaver.appex
+```
+
+In production the appex must be embedded inside an application bundle:
+
+```
+YourApp.app/Contents/PlugIns/YourScreensaver.appex
+```
+
+You typically ship the host app to `/Applications/`, and macOS picks the appex up automatically (or your host app calls `pluginkit -a` on first launch, as this sample does in `PluginManager.swift`).
+
+---
+
+## 7. Thumbnails
+
+### Required dimensions
+
+System Settings displays screensaver thumbnails at a specific landscape aspect ratio. Square images won't appear.
+
+| Scale | Dimensions |
+|-------|------------|
+| 1x | 107 × 65 |
+| 2x | 214 × 130 |
+
+### Asset catalog setup
+
+Place thumbnails in an asset catalog imageset named `thumbnail`:
+
+```
+Assets.xcassets/
+└── thumbnail.imageset/
+ ├── Contents.json
+ ├── thumbnail.png (107 × 65)
+ └── thumbnail@2x.png (214 × 130)
+```
+
+`Contents.json`:
+
+```json
+{
+ "images": [
+ { "filename": "thumbnail.png", "idiom": "universal", "scale": "1x" },
+ { "filename": "thumbnail@2x.png", "idiom": "universal", "scale": "2x" }
+ ],
+ "info": { "author": "xcode", "version": 1 }
+}
+```
+
+Some Apple screensavers ship both explicit PNGs in `Resources/` and entries inside the compiled `Assets.car`; the explicit PNGs appear to take priority when present.
+
+### Caching
+
+macOS caches screensaver thumbnails aggressively. If yours doesn't appear after changes:
+
+1. Rebuild and re-register: `pluginkit -a /path/to/Extension.appex`
+2. Close and reopen System Settings
+3. Log out and back in if the cached version persists
+
+---
+
+## References
+
+- `ScreenSaver.framework` headers in the macOS SDK
+- Apple's built-in screensaver extensions at `/System/Library/ExtensionKit/Extensions/`
+- ExtensionKit documentation in the Xcode SDK
+- [Aerial](https://github.com/AerialScreensaver/Aerial) — full appex screensaver using approaches A and C together
+- [ScreenSaverMinimal](https://github.com/AerialScreensaver/ScreenSaverMinimal) — same author's sample for the legacy `.saver` format
diff --git a/apps/Omacy/BuildNumber.xcconfig b/apps/Omacy/BuildNumber.xcconfig
new file mode 100644
index 0000000..32736bb
--- /dev/null
+++ b/apps/Omacy/BuildNumber.xcconfig
@@ -0,0 +1 @@
+CURRENT_PROJECT_VERSION = 260827.2045
diff --git a/apps/Omacy/LICENSE b/apps/Omacy/LICENSE
new file mode 100644
index 0000000..1a6259a
--- /dev/null
+++ b/apps/Omacy/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Guillaume Louel
+
+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/apps/Omacy/Omacy.xcodeproj/project.pbxproj b/apps/Omacy/Omacy.xcodeproj/project.pbxproj
new file mode 100644
index 0000000..feb4790
--- /dev/null
+++ b/apps/Omacy/Omacy.xcodeproj/project.pbxproj
@@ -0,0 +1,825 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 56;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1A000001000000000000000A /* OmacyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000001000000000000000B /* OmacyApp.swift */; };
+ 1A000002000000000000000A /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000002000000000000000B /* ContentView.swift */; };
+ 1A000003000000000000000A /* OmacyScreensaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000003000000000000000B /* OmacyScreensaver.swift */; };
+ 1A000004000000000000000A /* OmacySaverViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000004000000000000000B /* OmacySaverViewController.swift */; };
+ 1A000005000000000000000A /* OmacySaverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000005000000000000000B /* OmacySaverView.swift */; };
+ 1A000006000000000000000A /* ScreenSaver.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A000006000000000000000B /* ScreenSaver.framework */; };
+ 1A000007000000000000000A /* OmacyScreensaver.appex in Embed PlugIns */ = {isa = PBXBuildFile; fileRef = 1A000007000000000000000B /* OmacyScreensaver.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 1A000030000000000000000A /* OmacyConfigurationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000030000000000000000B /* OmacyConfigurationViewController.swift */; };
+ 1A000031000000000000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A000031000000000000000B /* Assets.xcassets */; };
+ 1A000032000000000000000A /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000032000000000000000B /* PluginManager.swift */; };
+ 1A000060000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000060000000000000000B /* OmacyCanaryAnimator.swift */; };
+ 1A000061000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000060000000000000000B /* OmacyCanaryAnimator.swift */; };
+ 1A000062000000000000000A /* PreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000062000000000000000B /* PreviewView.swift */; };
+ 1A000063000000000000000A /* PreviewViewRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000063000000000000000B /* PreviewViewRepresentable.swift */; };
+ 1A000066000000000000000A /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000064000000000000000B /* Logger.swift */; };
+ 1A000067000000000000000A /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000064000000000000000B /* Logger.swift */; };
+ 1A000070000000000000000A /* PaperSaverKit in Frameworks */ = {isa = PBXBuildFile; productRef = 1A000070000000000000000B /* PaperSaverKit */; };
+ 1A0000A0000000000000000A /* OmacyRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000090000000000000000B /* OmacyRenderer.swift */; };
+ 1A0000A1000000000000000A /* OmacyRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000090000000000000000B /* OmacyRenderer.swift */; };
+ 1A0000A2000000000000000A /* OmacyLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000091000000000000000B /* OmacyLayout.swift */; };
+ 1A0000A3000000000000000A /* OmacyLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000091000000000000000B /* OmacyLayout.swift */; };
+ 1A0000A4000000000000000A /* OmacyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000092000000000000000B /* OmacyStore.swift */; };
+ 1A0000A5000000000000000A /* OmacyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000092000000000000000B /* OmacyStore.swift */; };
+ 1A0000A6000000000000000A /* OmacyAtlas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000093000000000000000B /* OmacyAtlas.swift */; };
+ 1A0000A7000000000000000A /* OmacyAtlas.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000093000000000000000B /* OmacyAtlas.swift */; };
+ 1A0000A8000000000000000A /* OmacyShaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 1A000094000000000000000B /* OmacyShaders.metal */; };
+ 1A0000A9000000000000000A /* OmacyShaders.metal in Sources */ = {isa = PBXBuildFile; fileRef = 1A000094000000000000000B /* OmacyShaders.metal */; };
+ 1A0000AA000000000000000A /* OmacyHostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000095000000000000000B /* OmacyHostView.swift */; };
+ 1A0000AB000000000000000A /* ConfigView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A000096000000000000000B /* ConfigView.swift */; };
+ 1A0000AC000000000000000A /* FairfaxHD.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A000097000000000000000B /* FairfaxHD.ttf */; };
+ 1A0000AD000000000000000A /* FairfaxHD.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1A000097000000000000000B /* FairfaxHD.ttf */; };
+ 1A0000AE000000000000000A /* screensaver.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000098000000000000000B /* screensaver.txt */; };
+ 1A0000AF000000000000000A /* screensaver.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000098000000000000000B /* screensaver.txt */; };
+ 1A0000B0000000000000000A /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000099000000000000000B /* OFL.txt */; };
+ 1A0000B1000000000000000A /* OFL.txt in Resources */ = {isa = PBXBuildFile; fileRef = 1A000099000000000000000B /* OFL.txt */; };
+ 1A0000B2000000000000000A /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009A000000000000000B /* Metal.framework */; };
+ 1A0000B3000000000000000A /* Metal.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009A000000000000000B /* Metal.framework */; };
+ 1A0000B4000000000000000A /* libomacy_engine.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009B000000000000000B /* libomacy_engine.a */; };
+ 1A0000B5000000000000000A /* libomacy_engine.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 1A00009B000000000000000B /* libomacy_engine.a */; };
+ 1A0000B6000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */; };
+ 1A0000B7000000000000000A /* OmacyCanaryAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */; };
+ 1A0000E0000000000000000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A0000E0000000000000000B /* Assets.xcassets */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 1A000008000000000000000A /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 1A000000000000000000000A /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 1A000010000000000000000A;
+ remoteInfo = OmacyScreensaver;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 1A000009000000000000000A /* Embed PlugIns */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 1A000007000000000000000A /* OmacyScreensaver.appex in Embed PlugIns */,
+ );
+ name = "Embed PlugIns";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1A000001000000000000000B /* OmacyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyApp.swift; sourceTree = ""; };
+ 1A000002000000000000000B /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
+ 1A000003000000000000000B /* OmacyScreensaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyScreensaver.swift; sourceTree = ""; };
+ 1A000004000000000000000B /* OmacySaverViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacySaverViewController.swift; sourceTree = ""; };
+ 1A000005000000000000000B /* OmacySaverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacySaverView.swift; sourceTree = ""; };
+ 1A000006000000000000000B /* ScreenSaver.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ScreenSaver.framework; path = System/Library/Frameworks/ScreenSaver.framework; sourceTree = SDKROOT; };
+ 1A000007000000000000000B /* OmacyScreensaver.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = OmacyScreensaver.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1A00000A000000000000000B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 1A00000B000000000000000B /* Omacy.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Omacy.entitlements; sourceTree = ""; };
+ 1A00000C000000000000000B /* Omacy.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Omacy.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1A00000D000000000000000B /* ScreenSaverPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ScreenSaverPrivate.h; sourceTree = ""; };
+ 1A00000E000000000000000B /* OmacyScreensaver-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OmacyScreensaver-Bridging-Header.h"; sourceTree = ""; };
+ 1A00000F000000000000000B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 1A000011000000000000000B /* OmacyScreensaver.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OmacyScreensaver.entitlements; sourceTree = ""; };
+ 1A000030000000000000000B /* OmacyConfigurationViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyConfigurationViewController.swift; sourceTree = ""; };
+ 1A000031000000000000000B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 1A000032000000000000000B /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; };
+ 1A000050000000000000000B /* BuildNumber.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = BuildNumber.xcconfig; sourceTree = ""; };
+ 1A000060000000000000000B /* OmacyCanaryAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyCanaryAnimator.swift; sourceTree = ""; };
+ 1A000062000000000000000B /* PreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewView.swift; sourceTree = ""; };
+ 1A000063000000000000000B /* PreviewViewRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewViewRepresentable.swift; sourceTree = ""; };
+ 1A000064000000000000000B /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; };
+ 1A000090000000000000000B /* OmacyRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyRenderer.swift; sourceTree = ""; };
+ 1A000091000000000000000B /* OmacyLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyLayout.swift; sourceTree = ""; };
+ 1A000092000000000000000B /* OmacyStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyStore.swift; sourceTree = ""; };
+ 1A000093000000000000000B /* OmacyAtlas.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyAtlas.swift; sourceTree = ""; };
+ 1A000094000000000000000B /* OmacyShaders.metal */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.metal; path = OmacyShaders.metal; sourceTree = ""; };
+ 1A000095000000000000000B /* OmacyHostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyHostView.swift; sourceTree = ""; };
+ 1A000096000000000000000B /* ConfigView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigView.swift; sourceTree = ""; };
+ 1A000097000000000000000B /* FairfaxHD.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; name = FairfaxHD.ttf; path = ../../assets/fonts/FairfaxHD.ttf; sourceTree = SOURCE_ROOT; };
+ 1A000098000000000000000B /* screensaver.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = screensaver.txt; path = ../../assets/branding/screensaver.txt; sourceTree = SOURCE_ROOT; };
+ 1A000099000000000000000B /* OFL.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = OFL.txt; path = ../../assets/fonts/OFL.txt; sourceTree = SOURCE_ROOT; };
+ 1A00009A000000000000000B /* Metal.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Metal.framework; path = System/Library/Frameworks/Metal.framework; sourceTree = SDKROOT; };
+ 1A00009B000000000000000B /* libomacy_engine.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libomacy_engine.a; sourceTree = BUILT_PRODUCTS_DIR; };
+ 1A00009C000000000000000B /* Omacy-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Omacy-Bridging-Header.h"; sourceTree = ""; };
+ 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmacyCanaryAnimator.swift; sourceTree = ""; };
+ 1A0000E0000000000000000B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 1A000012000000000000000A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A000070000000000000000A /* PaperSaverKit in Frameworks */,
+ 1A0000B2000000000000000A /* Metal.framework in Frameworks */,
+ 1A0000B4000000000000000A /* libomacy_engine.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 1A000013000000000000000A /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A000006000000000000000A /* ScreenSaver.framework in Frameworks */,
+ 1A0000B3000000000000000A /* Metal.framework in Frameworks */,
+ 1A0000B5000000000000000A /* libomacy_engine.a in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 1A000014000000000000000C = {
+ isa = PBXGroup;
+ children = (
+ 1A000050000000000000000B /* BuildNumber.xcconfig */,
+ 1A000015000000000000000C /* Omacy */,
+ 1A000016000000000000000C /* OmacyScreensaver */,
+ 1A0000C0000000000000000C /* Shared */,
+ 1A000017000000000000000C /* Frameworks */,
+ 1A000018000000000000000C /* Products */,
+ );
+ sourceTree = "";
+ };
+ 1A000015000000000000000C /* Omacy */ = {
+ isa = PBXGroup;
+ children = (
+ 1A000065000000000000000C /* Helpers */,
+ 1A000001000000000000000B /* OmacyApp.swift */,
+ 1A000002000000000000000B /* ContentView.swift */,
+ 1A000032000000000000000B /* PluginManager.swift */,
+ 1A000095000000000000000B /* OmacyHostView.swift */,
+ 1A000096000000000000000B /* ConfigView.swift */,
+ 1A000062000000000000000B /* PreviewView.swift */,
+ 1A000063000000000000000B /* PreviewViewRepresentable.swift */,
+ 1A00009C000000000000000B /* Omacy-Bridging-Header.h */,
+ 1A0000E0000000000000000B /* Assets.xcassets */,
+ 1A00000A000000000000000B /* Info.plist */,
+ 1A00000B000000000000000B /* Omacy.entitlements */,
+ );
+ path = Omacy;
+ sourceTree = "";
+ };
+ 1A000065000000000000000C /* Helpers */ = {
+ isa = PBXGroup;
+ children = (
+ 1A000064000000000000000B /* Logger.swift */,
+ );
+ path = Helpers;
+ sourceTree = "";
+ };
+ 1A000016000000000000000C /* OmacyScreensaver */ = {
+ isa = PBXGroup;
+ children = (
+ 1A000019000000000000000C /* PrivateHeaders */,
+ 1A000003000000000000000B /* OmacyScreensaver.swift */,
+ 1A000004000000000000000B /* OmacySaverViewController.swift */,
+ 1A000005000000000000000B /* OmacySaverView.swift */,
+ 1A000030000000000000000B /* OmacyConfigurationViewController.swift */,
+ 1A000031000000000000000B /* Assets.xcassets */,
+ 1A00000E000000000000000B /* OmacyScreensaver-Bridging-Header.h */,
+ 1A00000F000000000000000B /* Info.plist */,
+ 1A000011000000000000000B /* OmacyScreensaver.entitlements */,
+ );
+ path = OmacyScreensaver;
+ sourceTree = "";
+ };
+ 1A0000C0000000000000000C /* Shared */ = {
+ isa = PBXGroup;
+ children = (
+ 1A00009D000000000000000B /* OmacyCanaryAnimator.swift */,
+ 1A000090000000000000000B /* OmacyRenderer.swift */,
+ 1A000091000000000000000B /* OmacyLayout.swift */,
+ 1A000092000000000000000B /* OmacyStore.swift */,
+ 1A000093000000000000000B /* OmacyAtlas.swift */,
+ 1A000094000000000000000B /* OmacyShaders.metal */,
+ 1A000097000000000000000B /* FairfaxHD.ttf */,
+ 1A000098000000000000000B /* screensaver.txt */,
+ 1A000099000000000000000B /* OFL.txt */,
+ );
+ path = Shared;
+ sourceTree = "";
+ };
+ 1A000017000000000000000C /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 1A000006000000000000000B /* ScreenSaver.framework */,
+ 1A00009A000000000000000B /* Metal.framework */,
+ 1A00009B000000000000000B /* libomacy_engine.a */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
+ 1A000018000000000000000C /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 1A00000C000000000000000B /* Omacy.app */,
+ 1A000007000000000000000B /* OmacyScreensaver.appex */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 1A000019000000000000000C /* PrivateHeaders */ = {
+ isa = PBXGroup;
+ children = (
+ 1A00000D000000000000000B /* ScreenSaverPrivate.h */,
+ );
+ path = PrivateHeaders;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 1A000010000000000000000A /* OmacyScreensaver */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 1A00001F000000000000000A /* Build configuration list for PBXNativeTarget "OmacyScreensaver" */;
+ buildPhases = (
+ 1A0000D1000000000000000A /* Build Rust engine */,
+ 1A000020000000000000000A /* Sources */,
+ 1A000013000000000000000A /* Frameworks */,
+ 1A000021000000000000000A /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = OmacyScreensaver;
+ productName = OmacyScreensaver;
+ productReference = 1A000007000000000000000B /* OmacyScreensaver.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ 1A00001A000000000000000A /* Omacy */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 1A00001B000000000000000A /* Build configuration list for PBXNativeTarget "Omacy" */;
+ buildPhases = (
+ 1A00001C000000000000000A /* Sources */,
+ 1A000012000000000000000A /* Frameworks */,
+ 1A00001D000000000000000A /* Resources */,
+ 1A000009000000000000000A /* Embed PlugIns */,
+ 1A000042000000000000000A /* Register Extensions */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 1A00001E000000000000000A /* PBXTargetDependency */,
+ );
+ name = Omacy;
+ packageProductDependencies = (
+ 1A000070000000000000000B /* PaperSaverKit */,
+ );
+ productName = Omacy;
+ productReference = 1A00000C000000000000000B /* Omacy.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 1A000000000000000000000A /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = 1;
+ LastSwiftUpdateCheck = 1500;
+ LastUpgradeCheck = 1500;
+ TargetAttributes = {
+ 1A000010000000000000000A = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ 1A00001A000000000000000A = {
+ CreatedOnToolsVersion = 15.0;
+ };
+ };
+ };
+ buildConfigurationList = 1A000022000000000000000A /* Build configuration list for PBXProject "Omacy" */;
+ compatibilityVersion = "Xcode 14.0";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 1A000014000000000000000C;
+ packageReferences = (
+ 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */,
+ );
+ productRefGroup = 1A000018000000000000000C /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 1A00001A000000000000000A /* Omacy */,
+ 1A000010000000000000000A /* OmacyScreensaver */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 1A00001D000000000000000A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A0000AC000000000000000A /* FairfaxHD.ttf in Resources */,
+ 1A0000AE000000000000000A /* screensaver.txt in Resources */,
+ 1A0000B0000000000000000A /* OFL.txt in Resources */,
+ 1A0000E0000000000000000A /* Assets.xcassets in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 1A000021000000000000000A /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A000031000000000000000A /* Assets.xcassets in Resources */,
+ 1A0000AD000000000000000A /* FairfaxHD.ttf in Resources */,
+ 1A0000AF000000000000000A /* screensaver.txt in Resources */,
+ 1A0000B1000000000000000A /* OFL.txt in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 1A0000D1000000000000000A /* Build Rust engine */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Build Rust engine";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(BUILT_PRODUCTS_DIR)/libomacy_engine.a",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "sh \"${SRCROOT}/scripts/build-engine.sh\"\n";
+ };
+ 1A000042000000000000000A /* Register Extensions */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ );
+ name = "Register Extensions";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "if [ -n \"$CI\" ]; then echo \"skip pluginkit on CI\"; exit 0; fi\nfind \"$CODESIGNING_FOLDER_PATH\" -name '*.appex' -exec pluginkit -a {} \\; || true\n";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 1A00001C000000000000000A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A000001000000000000000A /* OmacyApp.swift in Sources */,
+ 1A000002000000000000000A /* ContentView.swift in Sources */,
+ 1A000032000000000000000A /* PluginManager.swift in Sources */,
+ 1A000066000000000000000A /* Logger.swift in Sources */,
+ 1A0000A0000000000000000A /* OmacyRenderer.swift in Sources */,
+ 1A0000A2000000000000000A /* OmacyLayout.swift in Sources */,
+ 1A0000A4000000000000000A /* OmacyStore.swift in Sources */,
+ 1A0000A6000000000000000A /* OmacyAtlas.swift in Sources */,
+ 1A0000A8000000000000000A /* OmacyShaders.metal in Sources */,
+ 1A0000AA000000000000000A /* OmacyHostView.swift in Sources */,
+ 1A0000AB000000000000000A /* ConfigView.swift in Sources */,
+ 1A000062000000000000000A /* PreviewView.swift in Sources */,
+ 1A000063000000000000000A /* PreviewViewRepresentable.swift in Sources */,
+ 1A0000B6000000000000000A /* OmacyCanaryAnimator.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 1A000020000000000000000A /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 1A000003000000000000000A /* OmacyScreensaver.swift in Sources */,
+ 1A000004000000000000000A /* OmacySaverViewController.swift in Sources */,
+ 1A000005000000000000000A /* OmacySaverView.swift in Sources */,
+ 1A000030000000000000000A /* OmacyConfigurationViewController.swift in Sources */,
+ 1A000067000000000000000A /* Logger.swift in Sources */,
+ 1A0000B7000000000000000A /* OmacyCanaryAnimator.swift in Sources */,
+ 1A0000A1000000000000000A /* OmacyRenderer.swift in Sources */,
+ 1A0000A3000000000000000A /* OmacyLayout.swift in Sources */,
+ 1A0000A5000000000000000A /* OmacyStore.swift in Sources */,
+ 1A0000A7000000000000000A /* OmacyAtlas.swift in Sources */,
+ 1A0000A9000000000000000A /* OmacyShaders.metal in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 1A00001E000000000000000A /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 1A000010000000000000000A /* OmacyScreensaver */;
+ targetProxy = 1A000008000000000000000A /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin XCBuildConfiguration section */
+ 1A000023000000000000000A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1A000050000000000000000B /* BuildNumber.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
+ MTL_FAST_MATH = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = macosx;
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ };
+ name = Debug;
+ };
+ 1A000024000000000000000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 1A000050000000000000000B /* BuildNumber.xcconfig */;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_ENABLE_OBJC_WEAK = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu17;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ MTL_FAST_MATH = YES;
+ SDKROOT = macosx;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ };
+ name = Release;
+ };
+ 1A000025000000000000000A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Omacy/Omacy.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 25TVW8MSGJ;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Omacy/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ HEADER_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(SRCROOT)/OmacyScreensaver",
+ "$(SRCROOT)/../../crates/omacy-engine/include",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(BUILT_PRODUCTS_DIR)",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-lc++",
+ "-framework",
+ Security,
+ "-framework",
+ CoreFoundation,
+ "-framework",
+ SystemConfiguration,
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "Omacy/Omacy-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 1A000026000000000000000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
+ CODE_SIGN_ENTITLEMENTS = Omacy/Omacy.entitlements;
+ CODE_SIGN_IDENTITY = "Developer ID Application";
+ CODE_SIGN_STYLE = Manual;
+ COMBINE_HIDPI_IMAGES = YES;
+ DEVELOPMENT_TEAM = 25TVW8MSGJ;
+ ENABLE_HARDENED_RUNTIME = YES;
+ PROVISIONING_PROFILE_SPECIFIER = "match Direct be.zenjoy.omacy macos";
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = Omacy/Info.plist;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ INFOPLIST_KEY_NSPrincipalClass = NSApplication;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ HEADER_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(SRCROOT)/OmacyScreensaver",
+ "$(SRCROOT)/../../crates/omacy-engine/include",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(BUILT_PRODUCTS_DIR)",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-lc++",
+ "-framework",
+ Security,
+ "-framework",
+ CoreFoundation,
+ "-framework",
+ SystemConfiguration,
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "Omacy/Omacy-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 1A000027000000000000000A /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = OmacyScreensaver/OmacyScreensaver.entitlements;
+ CODE_SIGN_STYLE = Automatic;
+ DEVELOPMENT_TEAM = 25TVW8MSGJ;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
+ ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
+ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
+ ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
+ ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
+ ENABLE_RESOURCE_ACCESS_CAMERA = NO;
+ ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
+ ENABLE_RESOURCE_ACCESS_LOCATION = NO;
+ ENABLE_RESOURCE_ACCESS_PRINTING = NO;
+ ENABLE_RESOURCE_ACCESS_USB = NO;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = OmacyScreensaver/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Omacy;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy.screensaver;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ HEADER_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(SRCROOT)/OmacyScreensaver",
+ "$(SRCROOT)/../../crates/omacy-engine/include",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(BUILT_PRODUCTS_DIR)",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-lc++",
+ "-framework",
+ Security,
+ "-framework",
+ CoreFoundation,
+ "-framework",
+ SystemConfiguration,
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "OmacyScreensaver/OmacyScreensaver-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 1A000028000000000000000A /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_ENTITLEMENTS = OmacyScreensaver/OmacyScreensaver.entitlements;
+ CODE_SIGN_IDENTITY = "Developer ID Application";
+ CODE_SIGN_STYLE = Manual;
+ DEVELOPMENT_TEAM = 25TVW8MSGJ;
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_HARDENED_RUNTIME = YES;
+ PROVISIONING_PROFILE_SPECIFIER = "match Direct be.zenjoy.omacy.screensaver macos";
+ ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
+ ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
+ ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO;
+ ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO;
+ ENABLE_RESOURCE_ACCESS_CALENDARS = NO;
+ ENABLE_RESOURCE_ACCESS_CAMERA = NO;
+ ENABLE_RESOURCE_ACCESS_CONTACTS = NO;
+ ENABLE_RESOURCE_ACCESS_LOCATION = NO;
+ ENABLE_RESOURCE_ACCESS_PRINTING = NO;
+ ENABLE_RESOURCE_ACCESS_USB = NO;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GENERATE_INFOPLIST_FILE = NO;
+ INFOPLIST_FILE = OmacyScreensaver/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = Omacy;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = be.zenjoy.omacy.screensaver;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ HEADER_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(SRCROOT)/OmacyScreensaver",
+ "$(SRCROOT)/../../crates/omacy-engine/include",
+ );
+ LIBRARY_SEARCH_PATHS = (
+ "$(inherited)",
+ "$(BUILT_PRODUCTS_DIR)",
+ );
+ OTHER_LDFLAGS = (
+ "$(inherited)",
+ "-lc++",
+ "-framework",
+ Security,
+ "-framework",
+ CoreFoundation,
+ "-framework",
+ SystemConfiguration,
+ );
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_OBJC_BRIDGING_HEADER = "OmacyScreensaver/OmacyScreensaver-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 1A00001B000000000000000A /* Build configuration list for PBXNativeTarget "Omacy" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 1A000025000000000000000A /* Debug */,
+ 1A000026000000000000000A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 1A00001F000000000000000A /* Build configuration list for PBXNativeTarget "OmacyScreensaver" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 1A000027000000000000000A /* Debug */,
+ 1A000028000000000000000A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 1A000022000000000000000A /* Build configuration list for PBXProject "Omacy" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 1A000023000000000000000A /* Debug */,
+ 1A000024000000000000000A /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCRemoteSwiftPackageReference section */
+ 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/AerialScreensaver/PaperSaver.git";
+ requirement = {
+ kind = exactVersion;
+ version = 0.2.0;
+ };
+ };
+/* End XCRemoteSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 1A000070000000000000000B /* PaperSaverKit */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = 1A000071000000000000000A /* XCRemoteSwiftPackageReference "PaperSaver" */;
+ productName = PaperSaverKit;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 1A000000000000000000000A /* Project object */;
+}
diff --git a/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 0000000..86faf97
--- /dev/null
+++ b/apps/Omacy/Omacy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,24 @@
+{
+ "originHash" : "58d483173683d9748f852a167c8263542e3ef789aabcff2c1cc4cc0b5dad32d0",
+ "pins" : [
+ {
+ "identity" : "papersaver",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/AerialScreensaver/PaperSaver.git",
+ "state" : {
+ "revision" : "1f10d1537fdfa2df8165b46572a9de491ce2d084",
+ "version" : "0.2.0"
+ }
+ },
+ {
+ "identity" : "swift-argument-parser",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-argument-parser",
+ "state" : {
+ "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
+ "version" : "1.7.0"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme
new file mode 100644
index 0000000..0fc50e2
--- /dev/null
+++ b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/Omacy.xcscheme
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme
new file mode 100644
index 0000000..18f4515
--- /dev/null
+++ b/apps/Omacy/Omacy.xcodeproj/xcshareddata/xcschemes/OmacyScreensaver.xcscheme
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..2b75839
--- /dev/null
+++ b/apps/Omacy/Omacy/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.949",
+ "green" : "0.451",
+ "red" : "0.149"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..e8aaf0c
--- /dev/null
+++ b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ { "idiom" : "mac", "size" : "16x16", "scale" : "1x", "filename" : "icon_16.png" },
+ { "idiom" : "mac", "size" : "16x16", "scale" : "2x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "1x", "filename" : "icon_32.png" },
+ { "idiom" : "mac", "size" : "32x32", "scale" : "2x", "filename" : "icon_64.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "1x", "filename" : "icon_128.png" },
+ { "idiom" : "mac", "size" : "128x128", "scale" : "2x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "1x", "filename" : "icon_256.png" },
+ { "idiom" : "mac", "size" : "256x256", "scale" : "2x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "1x", "filename" : "icon_512.png" },
+ { "idiom" : "mac", "size" : "512x512", "scale" : "2x", "filename" : "icon_1024.png" }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png
new file mode 100644
index 0000000..94ee268
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png
new file mode 100644
index 0000000..ba2ad9c
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_128.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png
new file mode 100644
index 0000000..65cbaee
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_16.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png
new file mode 100644
index 0000000..44e1d5d
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_256.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png
new file mode 100644
index 0000000..fea4966
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_32.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png
new file mode 100644
index 0000000..ddb4716
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_512.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png
new file mode 100644
index 0000000..d9f115b
Binary files /dev/null and b/apps/Omacy/Omacy/Assets.xcassets/AppIcon.appiconset/icon_64.png differ
diff --git a/apps/Omacy/Omacy/Assets.xcassets/Contents.json b/apps/Omacy/Omacy/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/apps/Omacy/Omacy/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/Omacy/Omacy/ConfigView.swift b/apps/Omacy/Omacy/ConfigView.swift
new file mode 100644
index 0000000..66b0589
--- /dev/null
+++ b/apps/Omacy/Omacy/ConfigView.swift
@@ -0,0 +1,130 @@
+import SwiftUI
+import UniformTypeIdentifiers
+
+struct ConfigView: View {
+ @State private var settings = OmacyStore.loadSettings()
+ @State private var art = OmacyStore.loadArt()
+ @State private var status = "Ready"
+ @State private var importer = false
+
+ var body: some View {
+ Form {
+ Section("Art") {
+ TextEditor(text: $art)
+ .font(.system(.body, design: .monospaced))
+ .frame(minHeight: 160)
+ HStack {
+ Button("Restore default wordmark") {
+ art = OmacyStore.bundledArt
+ persist()
+ }
+ Button("Open PNG or SVG…") { importer = true }
+ }
+ }
+ Section("Effect") {
+ TextField("random or ttfx name", text: $settings.effect)
+ ColorPicker(
+ "Background",
+ selection: Binding(
+ get: { color(from: settings.background) },
+ set: { settings.background = hex(from: $0) }
+ ),
+ supportsOpacity: false
+ )
+ }
+ Section("Conversion") {
+ Picker("Mode", selection: $settings.asciiMode) {
+ Text("Braille").tag("braille")
+ Text("Block").tag("block")
+ }
+ Stepper("Threshold \(settings.threshold)%", value: $settings.threshold, in: 0...100)
+ Toggle("Invert", isOn: $settings.invert)
+ }
+ Section("Type") {
+ Stepper("Font size \(Int(settings.fontSize)) pt", value: $settings.fontSize, in: 8...48)
+ }
+ Section("About") {
+ Text("Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx.")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ if let error = OmacyStore.lastLoadError {
+ Text(error).font(.caption).foregroundStyle(.red)
+ }
+ Text(status).font(.caption).foregroundStyle(.secondary)
+ Button("Save") { persist() }
+ .keyboardShortcut(.defaultAction)
+ }
+ .padding()
+ .frame(minWidth: 520, minHeight: 560)
+ .fileImporter(isPresented: $importer, allowedContentTypes: [.png, UTType(filenameExtension: "svg") ?? .xml]) { result in
+ switch result {
+ case .success(let url):
+ convert(url)
+ case .failure(let error):
+ status = error.localizedDescription
+ }
+ }
+ }
+
+ private func persist() {
+ do {
+ try OmacyStore.save(settings: settings, art: art)
+ status = "Saved to App Group"
+ } catch {
+ status = error.localizedDescription
+ }
+ }
+
+ private func convert(_ url: URL) {
+ guard url.startAccessingSecurityScopedResource() else {
+ status = "Could not read file"
+ return
+ }
+ defer { url.stopAccessingSecurityScopedResource() }
+ guard let data = try? Data(contentsOf: url) else {
+ status = "Could not read file"
+ return
+ }
+ var cfg = OmacyAsciiConfig()
+ cfg.mode = settings.asciiModeCode
+ cfg.width = 80
+ cfg.height = 26
+ cfg.threshold = UInt8(settings.threshold)
+ cfg.invert = settings.invert ? 1 : 0
+ cfg.trim = 1
+ var text: OpaquePointer?
+ let statusCode = data.withUnsafeBytes { raw in
+ omacy_ascii_from_bytes(&cfg, raw.bindMemory(to: UInt8.self).baseAddress, data.count, &text)
+ }
+ defer { omacy_text_free(text) }
+ guard statusCode == OMACY_OK, let text, let ptr = omacy_text_utf8(text) else {
+ status = "Conversion failed"
+ return
+ }
+ let n = omacy_text_len(text)
+ art = String(bytes: UnsafeBufferPointer(start: ptr, count: n).map { UInt8(bitPattern: $0) }, encoding: .utf8) ?? art
+ persist()
+ }
+
+ private func color(from hex: String) -> Color {
+ let t = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
+ guard t.count == 6, let n = UInt32(t, radix: 16) else { return .black }
+ return Color(
+ red: Double((n >> 16) & 0xFF) / 255,
+ green: Double((n >> 8) & 0xFF) / 255,
+ blue: Double(n & 0xFF) / 255
+ )
+ }
+
+ private func hex(from color: Color) -> String {
+ let ns = NSColor(color)
+ guard let rgb = ns.usingColorSpace(.sRGB) else { return "#000000" }
+ return String(
+ format: "#%02X%02X%02X",
+ Int(rgb.redComponent * 255),
+ Int(rgb.greenComponent * 255),
+ Int(rgb.blueComponent * 255)
+ )
+ }
+}
diff --git a/apps/Omacy/Omacy/ContentView.swift b/apps/Omacy/Omacy/ContentView.swift
new file mode 100644
index 0000000..8227970
--- /dev/null
+++ b/apps/Omacy/Omacy/ContentView.swift
@@ -0,0 +1,292 @@
+//
+// ContentView.swift
+// Omacy
+//
+// Copyright © 2026 Guillaume Louel. Licensed under the MIT License.
+//
+// Main view for the host application. Shows extension registration status
+// and provides install / uninstall and "set as active screensaver" actions.
+//
+
+import SwiftUI
+
+private let logger = AppexLog.logger("HostApp")
+
+struct ContentView: View {
+ @Environment(\.openWindow) private var openWindow
+ @StateObject private var pluginManager = PluginManager()
+ @State private var statusMessage = "Ready"
+
+ var body: some View {
+ VStack(spacing: 20) {
+ // MARK: - Header
+ Image(systemName: "tv")
+ .font(.system(size: 60))
+ .foregroundColor(.accentColor)
+
+ Text("Omacy")
+ .font(.largeTitle)
+ .fontWeight(.bold)
+
+ Text("Screensaver Extension")
+ .font(.title2)
+ .foregroundColor(.secondary)
+
+ Divider()
+ .padding(.horizontal, 40)
+
+ // MARK: - Extension Status
+ Text("Extension Status")
+ .font(.headline)
+
+ extensionStatusView
+ .padding(.horizontal, 20)
+
+ Divider()
+ .padding(.horizontal, 40)
+
+ // MARK: - Screensaver Activation
+ Text("Screensaver Activation")
+ .font(.headline)
+
+ screensaverActivationView
+ .padding(.horizontal, 20)
+
+ Divider()
+ .padding(.horizontal, 40)
+
+ // MARK: - Actions
+ HStack(spacing: 12) {
+ Button("Open Preview") {
+ openWindow(id: "preview")
+ }
+ .buttonStyle(.borderedProminent)
+
+ Button("Edit art") {
+ openWindow(id: "config")
+ }
+ .buttonStyle(.bordered)
+
+ Button("Open Screen Saver Settings") {
+ openScreenSaverSettings()
+ }
+ .buttonStyle(.bordered)
+ }
+
+ Text(statusMessage)
+ .font(.caption)
+ .foregroundColor(.secondary)
+ .padding(.top, 10)
+ .frame(maxWidth: .infinity)
+ .multilineTextAlignment(.center)
+
+ Text("Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx.")
+ .font(.caption2)
+ .foregroundColor(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .padding(40)
+ .fixedSize()
+ }
+
+ @ViewBuilder
+ private var extensionStatusView: some View {
+ GroupBox {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Circle()
+ .fill(pluginManager.isInstalled ? Color.green : Color.gray)
+ .frame(width: 10, height: 10)
+
+ if pluginManager.isInstalled {
+ Text("Installed")
+ .fontWeight(.medium)
+ if let version = pluginManager.installedVersion {
+ Text("(v\(version))")
+ .foregroundColor(.secondary)
+ }
+ } else {
+ Text("Not registered")
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if pluginManager.isLoading {
+ ProgressView()
+ .scaleEffect(0.7)
+ } else {
+ Button {
+ pluginManager.checkInstallationStatus()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .help("Refresh status")
+ }
+ }
+
+ if pluginManager.isInstalled {
+ if pluginManager.isPluginMissing {
+ Text("The registered screensaver extension is missing. Re-register it from this app.")
+ .font(.caption)
+ .foregroundColor(.orange)
+ }
+ if let path = pluginManager.installedPath {
+ HStack(alignment: .top) {
+ Text("Path:")
+ .foregroundColor(.secondary)
+ Text(path)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ .textSelection(.enabled)
+ }
+ .font(.caption)
+ }
+ if pluginManager.hasConflictingRegistrations {
+ Text("Registered in more than one place. Unregister extras so only /Applications or DerivedData remains.")
+ .font(.caption)
+ .foregroundColor(.orange)
+ ForEach(pluginManager.registeredPaths, id: \.self) { path in
+ Text(path)
+ .font(.caption2)
+ .lineLimit(2)
+ .truncationMode(.middle)
+ .textSelection(.enabled)
+ }
+ }
+ } else {
+ Text("Omacy is not in System Settings yet. Register the extension from this app — the preview still runs either way.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ if let embeddedVersion = pluginManager.embeddedVersion {
+ Text("Embedded version: \(embeddedVersion)")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ }
+
+ if let error = pluginManager.lastError {
+ Text(error)
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+
+ HStack {
+ Spacer()
+ if pluginManager.isInstalled && !pluginManager.isPluginMissing {
+ Button("Uninstall") {
+ uninstallExtension()
+ }
+ .buttonStyle(.bordered)
+ .disabled(pluginManager.isLoading)
+ } else {
+ Button(pluginManager.isPluginMissing ? "Re-register" : "Install") {
+ installExtension()
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(pluginManager.isLoading)
+ }
+ Spacer()
+ }
+ .padding(.top, 4)
+ }
+ .padding(8)
+ }
+ }
+
+ @ViewBuilder
+ private var screensaverActivationView: some View {
+ GroupBox {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ Circle()
+ .fill(pluginManager.isActiveScreensaver ? Color.green : Color.gray)
+ .frame(width: 10, height: 10)
+
+ if pluginManager.isActiveScreensaver {
+ Text("Active")
+ .fontWeight(.medium)
+ } else {
+ Text("Not Active")
+ .fontWeight(.medium)
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if pluginManager.isCheckingScreensaver {
+ ProgressView()
+ .scaleEffect(0.7)
+ } else {
+ Button {
+ pluginManager.checkScreensaverStatus()
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .buttonStyle(.borderless)
+ .help("Refresh status")
+ }
+ }
+
+ if let error = pluginManager.screensaverError {
+ Text(error)
+ .font(.caption)
+ .foregroundColor(.red)
+ }
+
+ if pluginManager.isInstalled && !pluginManager.isActiveScreensaver {
+ HStack {
+ Spacer()
+ Button("Enable as Screensaver") {
+ Task {
+ await pluginManager.enableAsScreensaver()
+ }
+ }
+ .buttonStyle(.borderedProminent)
+ .disabled(pluginManager.isCheckingScreensaver)
+ Spacer()
+ }
+ .padding(.top, 4)
+ }
+ }
+ .padding(8)
+ }
+ }
+
+ private func installExtension() {
+ statusMessage = "Installing extension..."
+ do {
+ try pluginManager.install()
+ statusMessage = "Extension installed successfully"
+ } catch {
+ statusMessage = "Install failed: \(error.localizedDescription)"
+ logger.error("Install failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func uninstallExtension() {
+ statusMessage = "Uninstalling extension..."
+ do {
+ try pluginManager.uninstall()
+ statusMessage = "Unregistered. Move Omacy to Trash to finish removing it. You can also delete the App Group data if you want a clean slate."
+ } catch {
+ statusMessage = "Uninstall failed: \(error.localizedDescription)"
+ logger.error("Uninstall failed: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ private func openScreenSaverSettings() {
+ // Tahoe keeps Screen Saver as a sheet on Wallpaper. The old
+ // ScreenSaver-Settings.extension URL no longer opens that pane.
+ guard let url = URL(string: "x-apple.systempreferences:com.apple.Wallpaper-Settings.extension?ScreenSaver") else {
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }
+}
+
+#Preview {
+ ContentView()
+}
diff --git a/apps/Omacy/Omacy/Helpers/Logger.swift b/apps/Omacy/Omacy/Helpers/Logger.swift
new file mode 100644
index 0000000..b427f2e
--- /dev/null
+++ b/apps/Omacy/Omacy/Helpers/Logger.swift
@@ -0,0 +1,23 @@
+//
+// Logger.swift
+// Omacy
+//
+// Copyright © 2026 Guillaume Louel. Licensed under the MIT License.
+//
+
+import Foundation
+import os.log
+
+/// Logging configuration shared between the host app and the screensaver extension.
+///
+/// Open Console.app and filter by:
+/// subsystem == "be.zenjoy.omacy"
+/// to see logs from the host app and the appex extension at the same time. Every
+/// log entry's category and PID make it clear which process the line came from.
+enum AppexLog {
+ static let subsystem = "be.zenjoy.omacy"
+
+ static func logger(_ category: String) -> Logger {
+ Logger(subsystem: subsystem, category: category)
+ }
+}
diff --git a/apps/Omacy/Omacy/Info.plist b/apps/Omacy/Omacy/Info.plist
new file mode 100644
index 0000000..2a58fe9
--- /dev/null
+++ b/apps/Omacy/Omacy/Info.plist
@@ -0,0 +1,30 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconFile
+
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSHumanReadableCopyright
+ Copyright 2026 Peter Dedene. Effects by Terminal Text Effects (ChrisBuilds), Rust engine ttfx.
+ NSPrincipalClass
+ NSApplication
+
+
diff --git a/apps/Omacy/Omacy/Omacy-Bridging-Header.h b/apps/Omacy/Omacy/Omacy-Bridging-Header.h
new file mode 100644
index 0000000..14f23ee
--- /dev/null
+++ b/apps/Omacy/Omacy/Omacy-Bridging-Header.h
@@ -0,0 +1 @@
+#import "omacy.h"
diff --git a/apps/Omacy/Omacy/Omacy.entitlements b/apps/Omacy/Omacy/Omacy.entitlements
new file mode 100644
index 0000000..93243a2
--- /dev/null
+++ b/apps/Omacy/Omacy/Omacy.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.app-groups
+
+ group.be.zenjoy.omacy
+
+
+
diff --git a/apps/Omacy/Omacy/OmacyApp.swift b/apps/Omacy/Omacy/OmacyApp.swift
new file mode 100644
index 0000000..7b96a78
--- /dev/null
+++ b/apps/Omacy/Omacy/OmacyApp.swift
@@ -0,0 +1,21 @@
+import SwiftUI
+
+@main
+struct OmacyApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ }
+
+ Window("Preview", id: "preview") {
+ PreviewViewRepresentable()
+ .ignoresSafeArea()
+ }
+ .defaultSize(width: 960, height: 600)
+
+ Window("Art", id: "config") {
+ ConfigView()
+ }
+ .defaultSize(width: 560, height: 640)
+ }
+}
diff --git a/apps/Omacy/Omacy/OmacyCanaryAnimator.swift b/apps/Omacy/Omacy/OmacyCanaryAnimator.swift
new file mode 100644
index 0000000..8f1ef6d
--- /dev/null
+++ b/apps/Omacy/Omacy/OmacyCanaryAnimator.swift
@@ -0,0 +1 @@
+// Moved to Shared/OmacyCanaryAnimator.swift
diff --git a/apps/Omacy/Omacy/OmacyHostView.swift b/apps/Omacy/Omacy/OmacyHostView.swift
new file mode 100644
index 0000000..d06f3c6
--- /dev/null
+++ b/apps/Omacy/Omacy/OmacyHostView.swift
@@ -0,0 +1,91 @@
+import AppKit
+
+final class OmacyHostView: NSView {
+ private let renderer = OmacyRenderer()
+ private let canary = OmacyCanaryAnimator()
+ private var usingCanary = false
+ private var observingConfig = false
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ renderer.onEngineUnavailable = { [weak self] in
+ self?.switchToCanary()
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ wantsLayer = true
+ renderer.onEngineUnavailable = { [weak self] in
+ self?.switchToCanary()
+ }
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window != nil {
+ start()
+ } else {
+ stop()
+ }
+ }
+
+ override func layout() {
+ super.layout()
+ if usingCanary {
+ canary.updateBounds(bounds)
+ } else {
+ renderer.updateGeometry()
+ }
+ }
+
+ func applyPendingConfig() {
+ renderer.applyPendingConfig()
+ }
+
+ private func start() {
+ stop()
+ observeConfig()
+ switch renderer.attach(to: self, isPreview: false) {
+ case .engine:
+ usingCanary = false
+ renderer.start()
+ case .canary:
+ switchToCanary()
+ }
+ }
+
+ private func stop() {
+ renderer.stop()
+ canary.stop()
+ if observingConfig {
+ NotificationCenter.default.removeObserver(self, name: .omacyConfigDidChange, object: nil)
+ observingConfig = false
+ }
+ }
+
+ private func switchToCanary() {
+ usingCanary = true
+ if let layer {
+ canary.attach(to: layer)
+ canary.updateBounds(bounds)
+ }
+ canary.start()
+ }
+
+ private func observeConfig() {
+ guard !observingConfig else { return }
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(configDidChange),
+ name: .omacyConfigDidChange,
+ object: nil
+ )
+ observingConfig = true
+ }
+
+ @objc private func configDidChange() {
+ applyPendingConfig()
+ }
+}
diff --git a/apps/Omacy/Omacy/PluginManager.swift b/apps/Omacy/Omacy/PluginManager.swift
new file mode 100644
index 0000000..d482b35
--- /dev/null
+++ b/apps/Omacy/Omacy/PluginManager.swift
@@ -0,0 +1,272 @@
+//
+// PluginManager.swift
+// Omacy
+//
+// Copyright © 2026 Guillaume Louel. Licensed under the MIT License.
+//
+// Drives extension registration (via pluginkit) and screensaver activation
+// (via PaperSaverKit) from the host app's UI.
+//
+
+import Foundation
+import PaperSaverKit
+
+private let logger = AppexLog.logger("PluginManager")
+
+@MainActor
+class PluginManager: ObservableObject {
+ @Published var isInstalled: Bool = false
+ @Published var installedVersion: String?
+ @Published var installedPath: String?
+ @Published var isLoading: Bool = false
+ @Published var lastError: String?
+ @Published var registeredPaths: [String] = []
+
+ /// Registered with PlugInKit but the appex file is gone.
+ var isPluginMissing: Bool {
+ guard isInstalled else { return false }
+ guard let path = installedPath, !path.isEmpty else { return true }
+ return !FileManager.default.fileExists(atPath: path)
+ }
+
+ /// PlugInKit is sticky if DerivedData and /Applications are both registered.
+ var hasConflictingRegistrations: Bool {
+ registeredPaths.count > 1
+ }
+
+ @Published var isActiveScreensaver: Bool = false
+ @Published var isCheckingScreensaver: Bool = false
+ @Published var screensaverError: String?
+
+ private let bundleIdentifier = "be.zenjoy.omacy.screensaver"
+ private let paperSaver = PaperSaver()
+ private let screensaverDisplayName = "Omacy"
+
+ /// Path to the embedded extension in the app bundle.
+ var embeddedExtensionPath: String? {
+ Bundle.main.builtInPlugInsURL?.appendingPathComponent("OmacyScreensaver.appex").path
+ }
+
+ /// Version of the embedded extension.
+ var embeddedVersion: String? {
+ guard let path = embeddedExtensionPath,
+ let bundle = Bundle(path: path),
+ let version = bundle.infoDictionary?["CFBundleShortVersionString"] as? String else {
+ return nil
+ }
+ return version
+ }
+
+ init() {
+ checkInstallationStatus()
+ checkScreensaverStatus()
+ }
+
+ /// Check if the extension is registered with pluginkit.
+ func checkInstallationStatus() {
+ isLoading = true
+ lastError = nil
+
+ Task {
+ do {
+ let matches = try await queryPluginKit()
+ await MainActor.run {
+ self.registeredPaths = matches.map(\.path).compactMap { $0 }
+ self.isInstalled = !matches.isEmpty
+ self.installedPath = matches.first?.path
+ self.installedVersion = matches.first?.version
+ self.isLoading = false
+ if matches.count > 1 {
+ self.lastError = "Registered in more than one place. Unregister extras so only /Applications or DerivedData remains."
+ }
+ }
+ } catch {
+ await MainActor.run {
+ self.isInstalled = false
+ self.installedPath = nil
+ self.installedVersion = nil
+ self.registeredPaths = []
+ self.isLoading = false
+ self.lastError = error.localizedDescription
+ }
+ }
+ }
+ }
+
+ private struct PluginMatch {
+ var path: String?
+ var version: String?
+ }
+
+ /// Query pluginkit for our extension's registration status.
+ /// Line format we look for:
+ /// `+ be.zenjoy.omacy.screensaver(1.0) `
+ private func queryPluginKit() async throws -> [PluginMatch] {
+ let output = try runProcess("/usr/bin/pluginkit", arguments: ["-m", "-v", "-p", "com.apple.screensaver"])
+
+ var matches: [PluginMatch] = []
+ let lines = output.components(separatedBy: "\n")
+ for line in lines {
+ if line.contains(bundleIdentifier) {
+ logger.info("Found extension in pluginkit output: \(line, privacy: .public)")
+
+ var version: String?
+ if let versionStart = line.firstIndex(of: "("),
+ let versionEnd = line.firstIndex(of: ")") {
+ let start = line.index(after: versionStart)
+ version = String(line[start.. Bool {
+ let path = Bundle.main.bundlePath
+ if path.hasPrefix("/Applications/") { return true }
+ if path.contains("/DerivedData/") { return true }
+ return false
+ }
+
+ /// Run a subprocess and return its combined stdout/stderr.
+ private func runProcess(_ path: String, arguments: [String]) throws -> String {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: path)
+ process.arguments = arguments
+
+ let pipe = Pipe()
+ process.standardOutput = pipe
+ process.standardError = pipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ let output = String(data: data, encoding: .utf8) ?? ""
+
+ logger.debug("Process output: \(output, privacy: .public)")
+
+ if process.terminationStatus != 0 {
+ // pluginkit returns non-zero when no matches are found, so don't treat it as fatal.
+ logger.warning("Process exited with status: \(process.terminationStatus)")
+ }
+
+ return output
+ }
+}
+
+enum PluginError: LocalizedError {
+ case embeddedExtensionNotFound
+ case extensionPathNotFound
+ case installationFailed(String)
+ case uninstallationFailed(String)
+ case notInApplications
+
+ var errorDescription: String? {
+ switch self {
+ case .embeddedExtensionNotFound:
+ return "Embedded extension not found in app bundle"
+ case .extensionPathNotFound:
+ return "Extension path not found"
+ case .installationFailed(let message):
+ return "Installation failed: \(message)"
+ case .uninstallationFailed(let message):
+ return "Uninstallation failed: \(message)"
+ case .notInApplications:
+ return "Move Omacy.app to /Applications, then register. The host will not register from a random path."
+ }
+ }
+}
diff --git a/apps/Omacy/Omacy/PreviewView.swift b/apps/Omacy/Omacy/PreviewView.swift
new file mode 100644
index 0000000..bc06b43
--- /dev/null
+++ b/apps/Omacy/Omacy/PreviewView.swift
@@ -0,0 +1,3 @@
+import AppKit
+
+typealias PreviewView = OmacyHostView
diff --git a/apps/Omacy/Omacy/PreviewViewRepresentable.swift b/apps/Omacy/Omacy/PreviewViewRepresentable.swift
new file mode 100644
index 0000000..fc7b822
--- /dev/null
+++ b/apps/Omacy/Omacy/PreviewViewRepresentable.swift
@@ -0,0 +1,6 @@
+import SwiftUI
+
+struct PreviewViewRepresentable: NSViewRepresentable {
+ func makeNSView(context: Context) -> PreviewView { PreviewView() }
+ func updateNSView(_ nsView: PreviewView, context: Context) { }
+}
diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json b/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json
new file mode 100644
index 0000000..0bbaa31
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/Contents.json
@@ -0,0 +1,18 @@
+{
+ "images" : [
+ {
+ "filename" : "thumbnail.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "thumbnail@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png
new file mode 100644
index 0000000..91b8863
Binary files /dev/null and b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail.png differ
diff --git a/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png
new file mode 100644
index 0000000..94dae9e
Binary files /dev/null and b/apps/Omacy/OmacyScreensaver/Assets.xcassets/thumbnail.imageset/thumbnail@2x.png differ
diff --git a/apps/Omacy/OmacyScreensaver/Info.plist b/apps/Omacy/OmacyScreensaver/Info.plist
new file mode 100644
index 0000000..394e2c9
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/Info.plist
@@ -0,0 +1,43 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Omacy
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 1.0
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.screensaver
+ NSExtensionPointVersion
+ 1.0
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).OmacyScreensaver
+
+ SSEHasConfigureSheet
+
+ SSENeedsAnimationTimer
+
+ ScreenSaverConfigurationSheetViewControllerClass
+ $(PRODUCT_MODULE_NAME).OmacyConfigurationViewController
+ ScreenSaverViewControllerClass
+ $(PRODUCT_MODULE_NAME).OmacySaverViewController
+
+
diff --git a/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift b/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift
new file mode 100644
index 0000000..f4c6bbf
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacyConfigurationViewController.swift
@@ -0,0 +1,82 @@
+import AppKit
+import ScreenSaver
+
+private let logger = AppexLog.logger("Configuration")
+
+@objc(OmacyConfigurationViewController)
+class OmacyConfigurationViewController: ScreenSaverConfigurationViewController {
+ private let effectField = NSTextField()
+ private let statusLabel = NSTextField(labelWithString: "")
+
+ override func loadView() {
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 360, height: 180))
+ let title = NSTextField(labelWithString: "Omacy")
+ title.font = NSFont.systemFont(ofSize: 18, weight: .medium)
+ title.alignment = .center
+ title.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(title)
+
+ let settings = OmacyStore.loadSettings()
+ effectField.stringValue = settings.effect
+ effectField.placeholderString = "random or a ttfx name"
+ effectField.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(effectField)
+
+ let restore = NSButton(title: "Restore default art", target: self, action: #selector(restoreArt))
+ restore.bezelStyle = .rounded
+ restore.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(restore)
+
+ let ok = NSButton(title: "OK", target: self, action: #selector(saveAndDismiss))
+ ok.bezelStyle = .rounded
+ ok.keyEquivalent = "\r"
+ ok.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(ok)
+
+ statusLabel.font = NSFont.systemFont(ofSize: 11)
+ statusLabel.textColor = .secondaryLabelColor
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(statusLabel)
+
+ NSLayoutConstraint.activate([
+ title.centerXAnchor.constraint(equalTo: container.centerXAnchor),
+ title.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
+ effectField.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
+ effectField.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
+ effectField.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
+ restore.leadingAnchor.constraint(equalTo: effectField.leadingAnchor),
+ restore.topAnchor.constraint(equalTo: effectField.bottomAnchor, constant: 12),
+ ok.trailingAnchor.constraint(equalTo: effectField.trailingAnchor),
+ ok.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -16),
+ statusLabel.leadingAnchor.constraint(equalTo: effectField.leadingAnchor),
+ statusLabel.bottomAnchor.constraint(equalTo: ok.topAnchor, constant: -8)
+ ])
+ self.view = container
+ self.preferredContentSize = NSSize(width: 360, height: 180)
+ }
+
+ @objc private func restoreArt() {
+ do {
+ try OmacyStore.restoreDefaultArt()
+ statusLabel.stringValue = "Default wordmark restored"
+ } catch {
+ statusLabel.stringValue = error.localizedDescription
+ }
+ }
+
+ @objc private func saveAndDismiss(_ sender: Any?) {
+ var settings = OmacyStore.loadSettings()
+ settings.effect = effectField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ if settings.effect.isEmpty { settings.effect = "random" }
+ do {
+ try OmacyStore.save(settings: settings, art: OmacyStore.loadArt())
+ } catch {
+ logger.error("save failed: \(error.localizedDescription, privacy: .public)")
+ }
+ if let window = self.view.window, let parent = window.sheetParent {
+ parent.endSheet(window)
+ } else {
+ self.dismiss(nil)
+ }
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/OmacySaverView.swift b/apps/Omacy/OmacyScreensaver/OmacySaverView.swift
new file mode 100644
index 0000000..23f3f40
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacySaverView.swift
@@ -0,0 +1,90 @@
+import ScreenSaver
+import QuartzCore
+
+private let logger = AppexLog.logger("View")
+
+final class OmacySaverView: ScreenSaverView {
+ private let renderer = OmacyRenderer()
+ private let canary = OmacyCanaryAnimator()
+ private var usingCanary = true
+
+ override init?(frame: NSRect, isPreview: Bool) {
+ logger.info("init(frame: \(frame.size.width, privacy: .public)x\(frame.size.height, privacy: .public), isPreview: \(isPreview))")
+ super.init(frame: frame, isPreview: isPreview)
+ wantsLayer = true
+ animationTimeInterval = 1.0 / 60.0
+ renderer.onEngineUnavailable = { [weak self] in
+ self?.switchToCanary()
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ wantsLayer = true
+ renderer.onEngineUnavailable = { [weak self] in
+ self?.switchToCanary()
+ }
+ }
+
+ deinit {
+ // teardown() is @MainActor (OmacyRenderer). stopAnimation / window-nil already ran.
+ logger.info("deinit")
+ }
+
+ override func startAnimation() {
+ logger.info("startAnimation()")
+ super.startAnimation()
+ start()
+ }
+
+ override func stopAnimation() {
+ logger.info("stopAnimation()")
+ teardown()
+ super.stopAnimation()
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ logger.info("viewDidMoveToWindow() hasWindow=\(self.window != nil)")
+ if window != nil {
+ start()
+ } else {
+ teardown()
+ }
+ }
+
+ override func layout() {
+ super.layout()
+ if usingCanary {
+ canary.updateBounds(bounds)
+ } else {
+ renderer.updateGeometry()
+ }
+ }
+
+ private func start() {
+ teardown()
+ let preview = isPreview || (bounds.width > 0 && bounds.width < 400)
+ switch renderer.attach(to: self, isPreview: preview) {
+ case .engine:
+ usingCanary = false
+ renderer.start()
+ case .canary:
+ switchToCanary()
+ }
+ }
+
+ private func switchToCanary() {
+ usingCanary = true
+ if let layer {
+ canary.attach(to: layer)
+ canary.updateBounds(bounds)
+ }
+ canary.start()
+ }
+
+ private func teardown() {
+ renderer.stop()
+ canary.stop()
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift b/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift
new file mode 100644
index 0000000..4154fc8
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacySaverViewController.swift
@@ -0,0 +1,57 @@
+//
+// OmacySaverViewController.swift
+// OmacyScreensaver
+//
+// Copyright © 2026 Guillaume Louel. Licensed under the MIT License.
+//
+// Main view controller for the screensaver. Specified as
+// ScreenSaverViewControllerClass in Info.plist as
+// `$(PRODUCT_MODULE_NAME).OmacySaverViewController`.
+//
+// Mirrors Apple's Arabesque.appex pattern. Prefer loadView(forFrame:isPreview:)
+// so each display and the Settings preview get the real frame; loadView() is
+// the fallback when the framework only calls the NSViewController path.
+//
+
+import AppKit
+import ScreenSaver
+
+private let logger = AppexLog.logger("ViewController")
+
+@objc(OmacySaverViewController)
+class OmacySaverViewController: ScreenSaverViewController {
+
+ /// Strong reference so the framework can't drop our view while we still own it.
+ private var saverView: OmacySaverView?
+
+ override init(nibName nibNameOrNil: NSNib.Name?, bundle nibBundleOrNil: Bundle?) {
+ logger.info("init(nibName:bundle:)")
+ super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
+ }
+
+ required init?(coder: NSCoder) {
+ logger.info("init(coder:)")
+ super.init(coder: coder)
+ }
+
+ deinit {
+ logger.info("deinit")
+ }
+
+ override func loadView() {
+ logger.info("loadView()")
+ let frame = NSScreen.main?.frame ?? NSRect(x: 0, y: 0, width: 1920, height: 1080)
+ installView(frame: frame, isPreview: frame.width < 400)
+ }
+
+ override func loadView(forFrame frame: NSRect, isPreview: Bool) {
+ logger.info("loadView(forFrame: \(frame.size.width, privacy: .public)x\(frame.size.height, privacy: .public), isPreview: \(isPreview))")
+ installView(frame: frame, isPreview: isPreview)
+ }
+
+ private func installView(frame: NSRect, isPreview: Bool) {
+ let view = OmacySaverView(frame: frame, isPreview: isPreview)
+ saverView = view
+ self.view = view ?? NSView(frame: frame)
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h b/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h
new file mode 100644
index 0000000..9d6a025
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver-Bridging-Header.h
@@ -0,0 +1,10 @@
+//
+// AppexSaverExtension-Bridging-Header.h
+// AppexSaverExtension
+//
+// Bridging header to expose private ScreenSaver API to Swift.
+//
+
+#import "PrivateHeaders/ScreenSaverPrivate.h"
+#import "omacy.h"
+
diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements
new file mode 100644
index 0000000..54cca65
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.entitlements
@@ -0,0 +1,20 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.app-groups
+
+ group.be.zenjoy.omacy
+
+ com.apple.security.cs.disable-library-validation
+
+ com.apple.security.temporary-exception.mach-lookup.global-name
+
+ com.apple.CARenderServer
+ com.apple.CoreDisplay.master
+ com.apple.ViewBridgeAuxiliary
+
+
+
diff --git a/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift
new file mode 100644
index 0000000..7619901
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/OmacyScreensaver.swift
@@ -0,0 +1,31 @@
+//
+// OmacyScreensaver.swift
+// OmacyScreensaver
+//
+// Copyright © 2026 Guillaume Louel. Licensed under the MIT License.
+//
+// Principal class for the screensaver extension. Specified as
+// NSExtensionPrincipalClass in Info.plist as
+// `$(PRODUCT_MODULE_NAME).OmacyScreensaver`.
+//
+// Following Apple's own screensavers (e.g. Arabesque.appex) we keep this
+// minimal — only implement init() and let the framework drive lifecycle.
+//
+
+import Foundation
+import ScreenSaver
+
+private let logger = AppexLog.logger("Extension")
+
+@objc(OmacyScreensaver)
+class OmacyScreensaver: ScreenSaverExtension {
+
+ @objc override init() {
+ logger.info("OmacyScreensaver.init() PID=\(ProcessInfo.processInfo.processIdentifier, privacy: .public)")
+ super.init()
+ }
+
+ deinit {
+ logger.info("OmacyScreensaver.deinit")
+ }
+}
diff --git a/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h b/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h
new file mode 100644
index 0000000..b08741e
--- /dev/null
+++ b/apps/Omacy/OmacyScreensaver/PrivateHeaders/ScreenSaverPrivate.h
@@ -0,0 +1,51 @@
+//
+// ScreenSaverPrivate.h
+// AppexSaverExtension
+//
+// Private API declarations for macOS screensaver extensions.
+// These classes exist in ScreenSaver.framework but are not publicly declared.
+// Discovered via reverse engineering Apple's screensaver appex bundles.
+//
+
+#import
+#import
+
+NS_ASSUME_NONNULL_BEGIN
+
+#pragma mark - ScreenSaverExtension
+
+/// Principal class for modern screensaver app extensions.
+/// Subclass this and specify it as NSExtensionPrincipalClass in Info.plist.
+@interface ScreenSaverExtension : NSObject
+
+- (instancetype)init;
+
+@end
+
+#pragma mark - ScreenSaverViewController
+
+/// Main view controller for screensaver animation.
+/// Specify your subclass name as ScreenSaverViewControllerClass in Info.plist.
+@interface ScreenSaverViewController : NSViewController
+
+/// The ScreenSaverView that provides the animation.
+@property (nonatomic, weak, nullable) ScreenSaverView *representedView;
+
+/// Whether the screensaver is currently animating.
+@property (nonatomic, getter=isAnimating) BOOL animating;
+
+/// Called to create the view for the given frame.
+/// Swift name: loadView(forFrame:isPreview:)
+- (void)loadViewForFrame:(NSRect)frame isPreview:(BOOL)isPreview NS_SWIFT_NAME(loadView(forFrame:isPreview:));
+
+@end
+
+#pragma mark - ScreenSaverConfigurationViewController
+
+/// View controller for the screensaver configuration sheet.
+/// Subclass this if SSEHasConfigureSheet is true in Info.plist.
+@interface ScreenSaverConfigurationViewController : NSViewController
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/apps/Omacy/README.md b/apps/Omacy/README.md
new file mode 100644
index 0000000..2dc89fc
--- /dev/null
+++ b/apps/Omacy/README.md
@@ -0,0 +1,17 @@
+# Omacy Mac host
+
+Xcode project for the host app and `com.apple.screensaver` appex.
+
+Build on macOS 15+ (deployment target 15.0). PaperSaver 0.2.0 needs Swift 6.2, so resolving packages wants Xcode 26. The appex target’s “Build Rust engine” phase runs `scripts/build-engine.sh` once; the host depends on the appex and links the same `libomacy_engine.a`.
+
+## Run
+
+1. Team is `25TVW8MSGJ` (Zenjoy). Sync Developer ID profiles with `bundle exec fastlane mac generate_signing` (once) or `mac sync_signing` (readonly).
+2. Build the Omacy scheme.
+3. For iteration, stay in DerivedData **or** copy the app to `/Applications` — not both.
+4. Use the host to register (`pluginkit`) and enable (PaperSaver). Registration from a random path is refused.
+5. Preview in the host. Idle / System Settings listing is the canary gate: see `docs/macos-gates.md` in the repo root.
+
+Force the asymmetric fixture (no engine) with UserDefaults `omacy.forceCanary`.
+
+App Group: `group.be.zenjoy.omacy` (`screensaver.txt`, `settings.json`).
diff --git a/apps/Omacy/Shared/OmacyAtlas.swift b/apps/Omacy/Shared/OmacyAtlas.swift
new file mode 100644
index 0000000..84bf3e4
--- /dev/null
+++ b/apps/Omacy/Shared/OmacyAtlas.swift
@@ -0,0 +1,164 @@
+import AppKit
+import CoreText
+import Metal
+
+struct AtlasGlyph {
+ var uvOrigin: SIMD2
+ var uvSize: SIMD2
+}
+
+final class OmacyAtlas {
+ private(set) var texture: MTLTexture?
+ private var map: [UInt32: AtlasGlyph] = [:]
+ private var extraCount = 0
+ private let extraCap = 256
+ private var cell: CGSize = .zero
+ private var pixels: [UInt8] = []
+ private var texWidth = 1
+ private var texHeight = 1
+ private var glyphW = 1
+ private var glyphH = 1
+ private var cols = 1
+ private var nextSlot = 1
+ private var builtCell: CGSize = .zero
+ private var builtScale: CGFloat = 0
+ private var builtFontName = ""
+ private var builtFontSize: CGFloat = 0
+
+ private let preload: [UInt32] = {
+ var codes: [UInt32] = Array(0x20...0x7E)
+ codes.append(contentsOf: 0x2800...0x28FF)
+ codes.append(contentsOf: [0x2580, 0x2584, 0x2588, 0x2591, 0x2592, 0x2593])
+ return codes
+ }()
+
+ func needsRebuild(font: NSFont, cell: CGSize, scale: CGFloat) -> Bool {
+ abs(cell.width - builtCell.width) >= 0.5
+ || abs(cell.height - builtCell.height) >= 0.5
+ || abs(scale - builtScale) >= 0.01
+ || font.fontName != builtFontName
+ || abs(font.pointSize - builtFontSize) >= 0.01
+ }
+
+ func rebuild(device: MTLDevice, font: NSFont, cell: CGSize, scale: CGFloat) {
+ self.cell = cell
+ builtCell = cell
+ builtScale = scale
+ builtFontName = font.fontName
+ builtFontSize = font.pointSize
+ extraCount = 0
+ map.removeAll()
+ nextSlot = 1
+ let scale = max(scale, 1)
+ glyphW = max(Int(ceil(cell.width * scale)), 1)
+ glyphH = max(Int(ceil(cell.height * scale)), 1)
+ let count = preload.count + extraCap + 1
+ cols = Int(ceil(sqrt(Double(count))))
+ let rows = Int(ceil(Double(count) / Double(cols)))
+ texWidth = max(cols * glyphW, 1)
+ texHeight = max(rows * glyphH, 1)
+ pixels = Array(repeating: 0, count: texWidth * texHeight * 4)
+ writeWhitePixel(x: 0, y: 0)
+ map[0] = AtlasGlyph(
+ uvOrigin: SIMD2(0, 0),
+ uvSize: SIMD2(1 / Float(texWidth), 1 / Float(texHeight))
+ )
+ let ctFont = font as CTFont
+ for scalar in preload {
+ _ = stamp(font: ctFont, scalar: scalar)
+ }
+ upload(device: device)
+ }
+
+ var whitePixel: AtlasGlyph {
+ map[0] ?? AtlasGlyph(uvOrigin: SIMD2(0, 0), uvSize: SIMD2(1 / Float(max(texWidth, 1)), 1 / Float(max(texHeight, 1))))
+ }
+
+ func glyph(for scalar: UInt32, font: NSFont, device: MTLDevice) -> AtlasGlyph? {
+ if let hit = map[scalar] { return hit }
+ if extraCount >= extraCap { return nil }
+ extraCount += 1
+ let g = stamp(font: font as CTFont, scalar: scalar)
+ upload(device: device)
+ return g
+ }
+
+ private func upload(device: MTLDevice) {
+ let desc = MTLTextureDescriptor.texture2DDescriptor(
+ pixelFormat: .rgba8Unorm,
+ width: texWidth,
+ height: texHeight,
+ mipmapped: false
+ )
+ desc.usage = [.shaderRead]
+ desc.storageMode = .shared
+ guard let texture = device.makeTexture(descriptor: desc) else { return }
+ pixels.withUnsafeBytes { raw in
+ texture.replace(
+ region: MTLRegionMake2D(0, 0, texWidth, texHeight),
+ mipmapLevel: 0,
+ withBytes: raw.baseAddress!,
+ bytesPerRow: texWidth * 4
+ )
+ }
+ self.texture = texture
+ }
+
+ private func writeWhitePixel(x: Int, y: Int) {
+ let i = (y * texWidth + x) * 4
+ if i + 3 < pixels.count {
+ pixels[i] = 255
+ pixels[i + 1] = 255
+ pixels[i + 2] = 255
+ pixels[i + 3] = 255
+ }
+ }
+
+ private func stamp(font: CTFont, scalar: UInt32) -> AtlasGlyph? {
+ guard UnicodeScalar(scalar) != nil else { return nil }
+ let slot = nextSlot
+ nextSlot += 1
+ let col = slot % cols
+ let row = slot / cols
+ let colorSpace = CGColorSpaceCreateDeviceRGB()
+ guard let ctx = CGContext(
+ data: nil,
+ width: glyphW,
+ height: glyphH,
+ bitsPerComponent: 8,
+ bytesPerRow: glyphW * 4,
+ space: colorSpace,
+ bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
+ ) else { return nil }
+ ctx.clear(CGRect(x: 0, y: 0, width: glyphW, height: glyphH))
+ ctx.setFillColor(CGColor(gray: 1, alpha: 1))
+ var utf16 = Array(String(UnicodeScalar(scalar)!).utf16)
+ var glyph: CGGlyph = 0
+ _ = utf16.withUnsafeMutableBufferPointer { buf in
+ CTFontGetGlyphsForCharacters(font, buf.baseAddress!, &glyph, 1)
+ }
+ var point = CGPoint(x: 0, y: CGFloat(glyphH) * 0.18)
+ CTFontDrawGlyphs(font, &glyph, &point, 1, ctx)
+ if let data = ctx.data {
+ let src = data.bindMemory(to: UInt8.self, capacity: glyphW * glyphH * 4)
+ let destX = col * glyphW
+ let destY = row * glyphH
+ for gy in 0.. NSFont {
+ if let url = OmacyStore.bundledFontURL,
+ let data = try? Data(contentsOf: url) as CFData,
+ let provider = CGDataProvider(data: data),
+ let cg = CGFont(provider),
+ let font = CTFontCreateWithGraphicsFont(cg, size, nil, nil) as NSFont? {
+ return font
+ }
+ return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
+ }
+}
diff --git a/apps/Omacy/Shared/OmacyLayout.swift b/apps/Omacy/Shared/OmacyLayout.swift
new file mode 100644
index 0000000..345a0ec
--- /dev/null
+++ b/apps/Omacy/Shared/OmacyLayout.swift
@@ -0,0 +1,73 @@
+import AppKit
+
+enum OmacyLayout {
+ static let defaultFontSize: CGFloat = 18
+ static let minCells = 1
+ static let maxAxis = 512
+ static let maxCells = 32_768
+
+ static func cellSize(font: NSFont) -> CGSize {
+ let advance = font.advancement(forGlyph: font.glyph(withName: "M")).width
+ let width = advance > 0 ? advance : font.maximumAdvancement.width
+ return CGSize(width: max(width, 1), height: max(font.pointSize, 1))
+ }
+
+ static func grid(view: NSView, font: NSFont) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) {
+ let scale = view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? 2
+ let backing = view.convertToBacking(view.bounds.size)
+ return grid(viewSize: view.bounds.size, backing: backing, scale: scale, font: font)
+ }
+
+ static func grid(viewSize: CGSize, scale: CGFloat, font: NSFont) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) {
+ grid(viewSize: viewSize, backing: CGSize(width: viewSize.width * scale, height: viewSize.height * scale), scale: scale, font: font)
+ }
+
+ static func grid(
+ viewSize: CGSize,
+ backing: CGSize,
+ scale: CGFloat,
+ font: NSFont
+ ) -> (cols: UInt32, rows: UInt32, cell: CGSize, origin: CGPoint) {
+ let points = treatedAsPoints(viewSize: viewSize, backing: backing, scale: scale)
+ let cell = cellSize(font: font)
+ var cols = max(Self.minCells, Int(floor(points.width / cell.width)))
+ var rows = max(Self.minCells, Int(floor(points.height / cell.height)))
+ cols = min(cols, Self.maxAxis)
+ rows = min(rows, Self.maxAxis)
+ if cols * rows > Self.maxCells {
+ let ratio = sqrt(Double(Self.maxCells) / Double(cols * rows))
+ cols = max(Self.minCells, Int(Double(cols) * ratio))
+ rows = max(Self.minCells, Int(Double(rows) * ratio))
+ }
+ let gridWidth = CGFloat(cols) * cell.width
+ let gridHeight = CGFloat(rows) * cell.height
+ let origin = CGPoint(
+ x: (points.width - gridWidth) / 2,
+ y: (points.height - gridHeight) / 2
+ )
+ return (UInt32(cols), UInt32(rows), cell, origin)
+ }
+
+ /// Tahoe may hand backing pixels as bounds. An exact 2× jump vs backingScale is scale, not a giant canvas.
+ static func treatedAsPoints(viewSize: CGSize, backing: CGSize, scale: CGFloat) -> CGSize {
+ guard scale > 1 else { return viewSize }
+ if abs(backing.width - viewSize.width * scale) < 1, abs(backing.height - viewSize.height * scale) < 1 {
+ return viewSize
+ }
+ if abs(backing.width - viewSize.width) < 1, abs(backing.height - viewSize.height) < 1 {
+ return CGSize(width: viewSize.width / scale, height: viewSize.height / scale)
+ }
+ return viewSize
+ }
+
+ static func drawableSize(viewSize: CGSize, scale: CGFloat) -> CGSize {
+ CGSize(
+ width: max((viewSize.width * scale).rounded(), 1),
+ height: max((viewSize.height * scale).rounded(), 1)
+ )
+ }
+
+ static func drawableSizeChanged(current: CGSize, proposed: CGSize) -> Bool {
+ abs(current.width - proposed.width) >= 1 || abs(current.height - proposed.height) >= 1
+ }
+}
diff --git a/apps/Omacy/Shared/OmacyRenderer.swift b/apps/Omacy/Shared/OmacyRenderer.swift
new file mode 100644
index 0000000..5a89f7f
--- /dev/null
+++ b/apps/Omacy/Shared/OmacyRenderer.swift
@@ -0,0 +1,461 @@
+import AppKit
+import Metal
+import QuartzCore
+
+private let log = AppexLog.logger("Renderer")
+private let occupancyBackground = UInt8(OMACY_CELL_HAS_BACKGROUND)
+private let occupancyGlyph = UInt8(OMACY_CELL_HAS_GLYPH)
+
+private struct QuadInstance {
+ var origin: SIMD2
+ var size: SIMD2
+ var uvOrigin: SIMD2
+ var uvSize: SIMD2
+ var color: SIMD4
+}
+
+enum OmacyAttachMode {
+ case engine
+ case canary
+}
+
+@MainActor
+final class OmacyRenderer: NSObject {
+ var onEngineUnavailable: (() -> Void)?
+
+ override init() {
+ super.init()
+ }
+
+ private weak var view: NSView?
+ private var session: OpaquePointer?
+ private var displayLink: CADisplayLink?
+ private var device: MTLDevice?
+ private var queue: MTLCommandQueue?
+ private var pipeline: MTLRenderPipelineState?
+ private var metalLayer: CAMetalLayer?
+ private let atlas = OmacyAtlas()
+ private var instanceBuffers: [MTLBuffer?] = [nil, nil, nil]
+ private var bufferBusy = [false, false, false]
+ private var bufferIndex = 0
+ private var lastTimestamp: CFTimeInterval?
+ private var fontSize: CGFloat = OmacyLayout.defaultFontSize
+ private var cols: UInt32 = 1
+ private var rows: UInt32 = 1
+ private var pendingCols: UInt32?
+ private var pendingRows: UInt32?
+ private var debounce: TimeInterval = 0
+ private var isPreview = false
+ private var stopped = true
+ private var copiedCells: [OmacyCell] = []
+ private var lastSettings = OmacySettings()
+ private var lastArt = ""
+
+ var usesEngine: Bool { session != nil }
+
+ @discardableResult
+ func attach(to view: NSView, isPreview: Bool) -> OmacyAttachMode {
+ stopSessionAndLink()
+ self.view = view
+ self.isPreview = isPreview
+ view.wantsLayer = true
+ lastSettings = OmacyStore.loadSettings()
+ lastArt = OmacyStore.loadArt()
+ fontSize = CGFloat(lastSettings.fontSize)
+ let font = OmacyFont.makeFont(size: fontSize)
+ let layout = OmacyLayout.grid(view: view, font: font)
+ cols = layout.cols
+ rows = layout.rows
+
+ if OmacyStore.forceCanary || !createSession(cols: cols, rows: rows, settings: lastSettings, art: lastArt) {
+ installPlainLayer(on: view)
+ return .canary
+ }
+ guard installMetal(on: view, font: font, cell: layout.cell) else {
+ destroySession()
+ installPlainLayer(on: view)
+ return .canary
+ }
+ return .engine
+ }
+
+ func start() {
+ guard let view, session != nil else { return }
+ stopped = false
+ displayLink?.invalidate()
+ let link = view.displayLink(target: self, selector: #selector(tick(_:)))
+ link.add(to: .main, forMode: .common)
+ if isPreview {
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 30, preferred: 30)
+ }
+ displayLink = link
+ NotificationCenter.default.addObserver(
+ self,
+ selector: #selector(willStop),
+ name: Notification.Name("com.apple.screensaver.willstop"),
+ object: nil
+ )
+ }
+
+ func stop() {
+ stopped = true
+ NotificationCenter.default.removeObserver(self)
+ stopSessionAndLink()
+ metalLayer = nil
+ pipeline = nil
+ queue = nil
+ device = nil
+ instanceBuffers = [nil, nil, nil]
+ bufferBusy = [false, false, false]
+ }
+
+ deinit {
+ if session != nil {
+ assertionFailure("OmacyRenderer.deinit with live session")
+ if Thread.isMainThread {
+ omacy_session_destroy(session)
+ }
+ }
+ }
+
+ func updateGeometry() {
+ guard let view, view.bounds.width > 1, view.bounds.height > 1 else { return }
+ let font = OmacyFont.makeFont(size: fontSize)
+ let layout = OmacyLayout.grid(view: view, font: font)
+ let scale = view.window?.backingScaleFactor ?? metalLayer?.contentsScale ?? view.layer?.contentsScale ?? 2
+ let size = OmacyLayout.drawableSize(viewSize: view.bounds.size, scale: scale)
+ if metalLayer?.contentsScale != scale {
+ metalLayer?.contentsScale = scale
+ }
+ if let metalLayer, OmacyLayout.drawableSizeChanged(current: metalLayer.drawableSize, proposed: size) {
+ metalLayer.drawableSize = size
+ }
+ metalLayer?.frame = view.bounds
+ if layout.cols != cols || layout.rows != rows {
+ pendingCols = layout.cols
+ pendingRows = layout.rows
+ debounce = CACurrentMediaTime()
+ }
+ if let device, atlas.needsRebuild(font: font, cell: layout.cell, scale: scale) {
+ atlas.rebuild(device: device, font: font, cell: layout.cell, scale: scale)
+ }
+ }
+
+ func applyPendingConfig() {
+ lastSettings = OmacyStore.loadSettings()
+ lastArt = OmacyStore.loadArt()
+ fontSize = CGFloat(lastSettings.fontSize)
+ guard let session else { return }
+ var cfg = OmacyPendingConfig()
+ lastArt.withCString { ptr in
+ cfg.ascii = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self)
+ cfg.ascii_len = lastArt.utf8.count
+ lastSettings.effect.withCString { eptr in
+ cfg.effect = UnsafeRawPointer(eptr).assumingMemoryBound(to: UInt8.self)
+ cfg.effect_len = lastSettings.effect.utf8.count
+ let bg = lastSettings.backgroundRGBA
+ cfg.bg_r = bg.0
+ cfg.bg_g = bg.1
+ cfg.bg_b = bg.2
+ cfg.bg_a = bg.3
+ _ = omacy_session_set_pending_config(session, &cfg)
+ }
+ }
+ updateGeometry()
+ }
+
+ @objc private func willStop() {
+ stop()
+ }
+
+ @objc private func tick(_ link: CADisplayLink) {
+ guard !stopped else { return }
+ guard let session else { return }
+ let now = link.timestamp
+ let elapsed: Double
+ if let lastTimestamp {
+ elapsed = now - lastTimestamp
+ } else {
+ elapsed = 1.0 / 60.0
+ }
+ lastTimestamp = now
+
+ var result = OmacyStepResult()
+ let status = omacy_session_step(session, elapsed, &result)
+ if status == OMACY_ERR_DEAD || status == OMACY_ERR_PANIC {
+ recoverDeadSession()
+ return
+ }
+ guard status == OMACY_OK, let cells = result.frame.cells else { return }
+
+ let count = Int(result.frame.cols) * Int(result.frame.rows)
+ copiedCells = Array(UnsafeBufferPointer(start: cells, count: count))
+ present(frame: result.frame, cells: copiedCells)
+ applyWaitingWork(result)
+ }
+
+ private func applyWaitingWork(_ result: OmacyStepResult) {
+ guard let session else { return }
+ if result.needs_begin_next != 0 {
+ let settings = OmacyStore.loadSettings()
+ if CGFloat(settings.fontSize) != fontSize {
+ fontSize = CGFloat(settings.fontSize)
+ lastSettings = settings
+ updateGeometry()
+ }
+ if CACurrentMediaTime() - debounce >= 0.05, let pc = pendingCols, let pr = pendingRows {
+ _ = omacy_session_resize(session, pc, pr)
+ cols = pc
+ rows = pr
+ pendingCols = nil
+ pendingRows = nil
+ }
+ _ = omacy_session_begin_next(session)
+ } else if let pc = pendingCols, let pr = pendingRows, CACurrentMediaTime() - debounce >= 0.05 {
+ _ = omacy_session_resize(session, pc, pr)
+ }
+ }
+
+ private func present(frame: OmacyFrame, cells: [OmacyCell]) {
+ guard let view, let metalLayer, let queue, let pipeline else { return }
+ let font = OmacyFont.makeFont(size: fontSize)
+ let layout = OmacyLayout.grid(view: view, font: font)
+ var instances: [QuadInstance] = []
+ instances.reserveCapacity(cells.count * 2)
+ let cell = layout.cell
+ let origin = layout.origin
+ let cols = Int(frame.cols)
+ let rows = Int(frame.rows)
+ let white = atlas.whitePixel
+ for r in 0..(Float(cell.width), Float(cell.height))
+ if cellData.occupancy & occupancyBackground != 0 {
+ instances.append(QuadInstance(
+ origin: SIMD2(px, py),
+ size: size,
+ uvOrigin: white.uvOrigin,
+ uvSize: white.uvSize,
+ color: SIMD4(
+ Float(cellData.bg_r) / 255,
+ Float(cellData.bg_g) / 255,
+ Float(cellData.bg_b) / 255,
+ Float(cellData.bg_a) / 255
+ )
+ ))
+ }
+ if cellData.occupancy & occupancyGlyph != 0, cellData.glyph != 0,
+ let device, let g = atlas.glyph(for: cellData.glyph, font: font, device: device) {
+ instances.append(QuadInstance(
+ origin: SIMD2(px, py),
+ size: size,
+ uvOrigin: g.uvOrigin,
+ uvSize: g.uvSize,
+ color: SIMD4(
+ Float(cellData.fg_r) / 255,
+ Float(cellData.fg_g) / 255,
+ Float(cellData.fg_b) / 255,
+ Float(cellData.fg_a) / 255
+ )
+ ))
+ }
+ }
+ }
+ var upload: (index: Int, buffer: MTLBuffer)?
+ if !instances.isEmpty {
+ guard let chosen = freeInstanceBufferIndex(), let device else { return }
+ let bytes = instances.count * MemoryLayout.stride
+ if instanceBuffers[chosen] == nil || instanceBuffers[chosen]!.length < bytes {
+ instanceBuffers[chosen] = device.makeBuffer(length: max(bytes, 4096), options: .storageModeShared)
+ }
+ guard let buffer = instanceBuffers[chosen] else { return }
+ instances.withUnsafeBytes { raw in
+ buffer.contents().copyMemory(from: raw.baseAddress!, byteCount: bytes)
+ }
+ upload = (chosen, buffer)
+ }
+
+ guard let drawable = metalLayer.nextDrawable(),
+ let cmd = queue.makeCommandBuffer() else { return }
+ let pass = MTLRenderPassDescriptor()
+ pass.colorAttachments[0].texture = drawable.texture
+ pass.colorAttachments[0].loadAction = .clear
+ pass.colorAttachments[0].clearColor = MTLClearColor(
+ red: Double(frame.clear_r) / 255,
+ green: Double(frame.clear_g) / 255,
+ blue: Double(frame.clear_b) / 255,
+ alpha: Double(max(frame.clear_a, 255)) / 255
+ )
+ pass.colorAttachments[0].storeAction = .store
+ guard let enc = cmd.makeRenderCommandEncoder(descriptor: pass) else { return }
+ enc.setRenderPipelineState(pipeline)
+ if let upload {
+ bufferBusy[upload.index] = true
+ bufferIndex = (upload.index + 1) % 3
+ enc.setVertexBuffer(upload.buffer, offset: 0, index: 0)
+ var viewport = SIMD2(Float(view.bounds.width), Float(view.bounds.height))
+ enc.setVertexBytes(&viewport, length: MemoryLayout>.size, index: 1)
+ enc.setFragmentTexture(atlas.texture, index: 0)
+ enc.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6, instanceCount: instances.count)
+ let chosen = upload.index
+ cmd.addCompletedHandler { [weak self] _ in
+ DispatchQueue.main.async {
+ self?.bufferBusy[chosen] = false
+ }
+ }
+ }
+ enc.endEncoding()
+ cmd.present(drawable)
+ cmd.commit()
+ }
+
+ private func freeInstanceBufferIndex() -> Int? {
+ for offset in 0..<3 {
+ let idx = (bufferIndex + offset) % 3
+ if !bufferBusy[idx] {
+ return idx
+ }
+ }
+ return nil
+ }
+
+ @discardableResult
+ private func createSession(cols: UInt32, rows: UInt32, settings: OmacySettings, art: String) -> Bool {
+ destroySession()
+ let bg = settings.backgroundRGBA
+ var cfg = OmacySessionConfig()
+ let artBytes = Array(art.utf8)
+ let effectBytes = Array(settings.effect.utf8)
+ let dir = OmacyStore.containerURL?.path
+ let dirBytes = dir.map { Array($0.utf8) }
+ var created = false
+ artBytes.withUnsafeBufferPointer { artPtr in
+ effectBytes.withUnsafeBufferPointer { effectPtr in
+ cfg.ascii = artPtr.baseAddress
+ cfg.ascii_len = artBytes.count
+ cfg.effect = effectPtr.baseAddress
+ cfg.effect_len = effectBytes.count
+ cfg.bg_r = bg.0
+ cfg.bg_g = bg.1
+ cfg.bg_b = bg.2
+ cfg.bg_a = bg.3
+ cfg.has_seed = 0
+ if let dirBytes {
+ dirBytes.withUnsafeBufferPointer { dirPtr in
+ cfg.config_dir = dirPtr.baseAddress
+ cfg.config_dir_len = dirBytes.count
+ created = finishCreate(&cfg, cols: cols, rows: rows)
+ }
+ } else {
+ created = finishCreate(&cfg, cols: cols, rows: rows)
+ }
+ }
+ }
+ return created
+ }
+
+ private func finishCreate(_ cfg: inout OmacySessionConfig, cols: UInt32, rows: UInt32) -> Bool {
+ var out: OpaquePointer?
+ let status = omacy_session_create(&cfg, cols, rows, &out)
+ if status == OMACY_OK, let out {
+ session = out
+ return true
+ }
+ log.error("session create failed: \(String(describing: status), privacy: .public)")
+ return false
+ }
+
+ private func recoverDeadSession() {
+ let geometryCols = pendingCols ?? cols
+ let geometryRows = pendingRows ?? rows
+ if createSession(cols: geometryCols, rows: geometryRows, settings: lastSettings, art: lastArt) {
+ cols = geometryCols
+ rows = geometryRows
+ pendingCols = nil
+ pendingRows = nil
+ return
+ }
+ failToCanary()
+ }
+
+ private func failToCanary() {
+ stopSessionAndLink()
+ stopped = true
+ metalLayer = nil
+ if let view {
+ installPlainLayer(on: view)
+ }
+ onEngineUnavailable?()
+ }
+
+ private func stopSessionAndLink() {
+ destroySession()
+ displayLink?.invalidate()
+ displayLink = nil
+ lastTimestamp = nil
+ }
+
+ private func destroySession() {
+ if let session {
+ omacy_session_destroy(session)
+ self.session = nil
+ }
+ }
+
+ private func installPlainLayer(on view: NSView) {
+ let layer = CALayer()
+ layer.backgroundColor = NSColor.black.cgColor
+ layer.isOpaque = true
+ view.layer = layer
+ metalLayer = nil
+ }
+
+ private func installMetal(on view: NSView, font: NSFont, cell: CGSize) -> Bool {
+ guard let device = MTLCreateSystemDefaultDevice() else {
+ log.error("no Metal device")
+ return false
+ }
+ self.device = device
+ queue = device.makeCommandQueue()
+ let scale = view.window?.backingScaleFactor ?? view.layer?.contentsScale ?? 2
+ let container = CALayer()
+ container.backgroundColor = NSColor.black.cgColor
+ container.isOpaque = true
+ let layer = CAMetalLayer()
+ layer.device = device
+ layer.pixelFormat = .bgra8Unorm
+ layer.framebufferOnly = true
+ layer.isOpaque = true
+ layer.contentsScale = scale
+ layer.drawableSize = OmacyLayout.drawableSize(viewSize: view.bounds.size, scale: scale)
+ layer.frame = view.bounds
+ layer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+ view.layer = container
+ container.addSublayer(layer)
+ metalLayer = layer
+ pipeline = makePipeline(device: device)
+ atlas.rebuild(device: device, font: font, cell: cell, scale: scale)
+ return pipeline != nil
+ }
+
+ private func makePipeline(device: MTLDevice) -> MTLRenderPipelineState? {
+ guard let library = device.makeDefaultLibrary() else { return nil }
+ return build(device: device, library: library)
+ }
+
+ private func build(device: MTLDevice, library: MTLLibrary) -> MTLRenderPipelineState? {
+ let desc = MTLRenderPipelineDescriptor()
+ desc.vertexFunction = library.makeFunction(name: "omacy_vertex")
+ desc.fragmentFunction = library.makeFunction(name: "omacy_fragment")
+ desc.colorAttachments[0].pixelFormat = .bgra8Unorm
+ desc.colorAttachments[0].isBlendingEnabled = true
+ desc.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha
+ desc.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha
+ desc.colorAttachments[0].sourceAlphaBlendFactor = .one
+ desc.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha
+ return try? device.makeRenderPipelineState(descriptor: desc)
+ }
+}
diff --git a/apps/Omacy/Shared/OmacyShaders.metal b/apps/Omacy/Shared/OmacyShaders.metal
new file mode 100644
index 0000000..8269bdf
--- /dev/null
+++ b/apps/Omacy/Shared/OmacyShaders.metal
@@ -0,0 +1,45 @@
+#include
+using namespace metal;
+
+struct Instance {
+ float2 origin;
+ float2 size;
+ float2 uvOrigin;
+ float2 uvSize;
+ float4 color;
+};
+
+struct VertexOut {
+ float4 position [[position]];
+ float2 uv;
+ float4 color;
+};
+
+vertex VertexOut omacy_vertex(uint vid [[vertex_id]],
+ uint iid [[instance_id]],
+ constant Instance *instances [[buffer(0)]],
+ constant float2 &viewport [[buffer(1)]]) {
+ const float2 corners[6] = {
+ float2(0.0, 0.0), float2(1.0, 0.0), float2(0.0, 1.0),
+ float2(1.0, 0.0), float2(1.0, 1.0), float2(0.0, 1.0)
+ };
+ Instance inst = instances[iid];
+ float2 pixel = inst.origin + corners[vid] * inst.size;
+ VertexOut out;
+ out.position = float4(
+ pixel.x / viewport.x * 2.0 - 1.0,
+ 1.0 - pixel.y / viewport.y * 2.0,
+ 0.0,
+ 1.0
+ );
+ out.uv = inst.uvOrigin + corners[vid] * inst.uvSize;
+ out.color = inst.color;
+ return out;
+}
+
+fragment float4 omacy_fragment(VertexOut in [[stage_in]],
+ texture2d atlas [[texture(0)]]) {
+ constexpr sampler s(address::clamp_to_edge, filter::linear);
+ float coverage = atlas.sample(s, in.uv).a;
+ return float4(in.color.rgb, in.color.a * coverage);
+}
diff --git a/apps/Omacy/Shared/OmacyStore.swift b/apps/Omacy/Shared/OmacyStore.swift
new file mode 100644
index 0000000..efbefc9
--- /dev/null
+++ b/apps/Omacy/Shared/OmacyStore.swift
@@ -0,0 +1,136 @@
+import Foundation
+import AppKit
+
+struct OmacySettings: Equatable {
+ var effect: String = "random"
+ var background: String = "#000000"
+ var fontSize: Double = 18
+ var asciiMode: String = "braille"
+ var threshold: Int = 50
+ var invert: Bool = false
+
+ var backgroundRGBA: (UInt8, UInt8, UInt8, UInt8) {
+ let hex = background.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
+ guard hex.count == 6, let n = UInt32(hex, radix: 16) else {
+ return (0, 0, 0, 255)
+ }
+ return (UInt8((n >> 16) & 0xFF), UInt8((n >> 8) & 0xFF), UInt8(n & 0xFF), 255)
+ }
+
+ var asciiModeCode: UInt32 {
+ asciiMode == "block" ? 1 : 0
+ }
+}
+
+extension Notification.Name {
+ static let omacyConfigDidChange = Notification.Name("omacy.configDidChange")
+}
+
+enum OmacyStore {
+ static let appGroup = "group.be.zenjoy.omacy"
+ static let forceCanaryKey = "omacy.forceCanary"
+
+ private static var lastGoodSettings = OmacySettings()
+ private static var lastGoodArt: String?
+ static private(set) var lastLoadError: String?
+
+ static var containerURL: URL? {
+ FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
+ }
+
+ static var settingsURL: URL? { containerURL?.appendingPathComponent("settings.json") }
+ static var artURL: URL? { containerURL?.appendingPathComponent("screensaver.txt") }
+
+ static var bundledArt: String {
+ if let url = Bundle.main.url(forResource: "screensaver", withExtension: "txt", subdirectory: nil)
+ ?? Bundle.main.url(forResource: "screensaver", withExtension: "txt"),
+ let text = try? String(contentsOf: url, encoding: .utf8) {
+ return text
+ }
+ return "OMACY"
+ }
+
+ static var bundledFontURL: URL? {
+ Bundle.main.url(forResource: "FairfaxHD", withExtension: "ttf")
+ ?? Bundle.main.url(forResource: "Fairfax", withExtension: "ttf")
+ }
+
+ static func loadSettings() -> OmacySettings {
+ guard let url = settingsURL else { return lastGoodSettings }
+ guard FileManager.default.fileExists(atPath: url.path) else { return lastGoodSettings }
+ do {
+ let data = try Data(contentsOf: url)
+ guard let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
+ lastLoadError = "settings.json is invalid; keeping last-known-good"
+ return lastGoodSettings
+ }
+ var s = OmacySettings()
+ if let effect = obj["effect"] as? String { s.effect = effect }
+ if let background = obj["background"] as? String { s.background = background }
+ if let fontSize = obj["fontSize"] as? Double { s.fontSize = fontSize }
+ if let asciiMode = obj["asciiMode"] as? String { s.asciiMode = asciiMode }
+ if let threshold = obj["threshold"] as? Int { s.threshold = threshold }
+ if let invert = obj["invert"] as? Bool { s.invert = invert }
+ lastGoodSettings = s
+ lastLoadError = nil
+ return s
+ } catch {
+ lastLoadError = "Could not read settings.json; keeping last-known-good"
+ return lastGoodSettings
+ }
+ }
+
+ static func loadArt() -> String {
+ if let url = artURL {
+ if let text = try? String(contentsOf: url, encoding: .utf8), !text.isEmpty {
+ lastGoodArt = text
+ return text
+ }
+ }
+ if let lastGoodArt { return lastGoodArt }
+ return bundledArt
+ }
+
+ static func save(settings: OmacySettings, art: String) throws {
+ guard let dir = containerURL else {
+ throw NSError(domain: "Omacy", code: 1, userInfo: [NSLocalizedDescriptionKey: "App Group container missing"])
+ }
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let payload: [String: Any] = [
+ "effect": settings.effect,
+ "background": settings.background,
+ "fontSize": settings.fontSize,
+ "asciiMode": settings.asciiMode,
+ "threshold": settings.threshold,
+ "invert": settings.invert
+ ]
+ let data = try JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys])
+ try atomicWrite(data, to: settingsURL!)
+ try atomicWrite(Data(art.utf8), to: artURL!)
+ lastGoodSettings = settings
+ lastGoodArt = art
+ lastLoadError = nil
+ NotificationCenter.default.post(name: .omacyConfigDidChange, object: nil)
+ }
+
+ static func restoreDefaultArt() throws {
+ try save(settings: loadSettings(), art: bundledArt)
+ }
+
+ static var forceCanary: Bool {
+ UserDefaults.standard.bool(forKey: forceCanaryKey)
+ }
+
+ private static func atomicWrite(_ data: Data, to url: URL) throws {
+ let tmp = url.deletingLastPathComponent().appendingPathComponent(".\(url.lastPathComponent).tmp")
+ if FileManager.default.fileExists(atPath: tmp.path) {
+ try FileManager.default.removeItem(at: tmp)
+ }
+ try data.write(to: tmp)
+ if FileManager.default.fileExists(atPath: url.path) {
+ _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
+ } else {
+ try FileManager.default.moveItem(at: tmp, to: url)
+ }
+ }
+}
diff --git a/apps/Omacy/scripts/build-engine.sh b/apps/Omacy/scripts/build-engine.sh
new file mode 100755
index 0000000..9eefa17
--- /dev/null
+++ b/apps/Omacy/scripts/build-engine.sh
@@ -0,0 +1,31 @@
+#!/bin/sh
+# Xcode run script: build libomacy_engine.a for the active Mac architecture.
+# Xcode script phases reset PATH to Apple's toolchain dirs, so rustup/cargo
+# from the developer machine or GitHub Actions must be put back explicitly.
+set -euo pipefail
+CARGO_BIN="${CARGO_HOME:-$HOME/.cargo}/bin"
+export PATH="${CARGO_BIN}:/opt/homebrew/bin:/usr/local/bin:${PATH}"
+if [ -f "${HOME}/.cargo/env" ]; then
+ # shellcheck source=/dev/null
+ . "${HOME}/.cargo/env"
+fi
+if ! command -v cargo >/dev/null 2>&1; then
+ echo "error: cargo not found on PATH=${PATH}" >&2
+ echo "error: install Rust 1.88.0 via rustup, then rebuild" >&2
+ exit 1
+fi
+ROOT="${SRCROOT}/../.."
+cd "$ROOT"
+ARCHS_EFFECTIVE="${ARCHS:-arm64}"
+case "$ARCHS_EFFECTIVE" in
+ *arm64*) RUST_TARGET="aarch64-apple-darwin" ;;
+ *x86_64*) RUST_TARGET="x86_64-apple-darwin" ;;
+ *) RUST_TARGET="aarch64-apple-darwin" ;;
+esac
+export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-15.0}"
+if command -v rustup >/dev/null 2>&1; then
+ rustup target add "$RUST_TARGET" >/dev/null 2>&1 || true
+fi
+cargo build --release --target "$RUST_TARGET" -p omacy-engine
+mkdir -p "${BUILT_PRODUCTS_DIR}"
+cp -f "target/${RUST_TARGET}/release/libomacy_engine.a" "${BUILT_PRODUCTS_DIR}/libomacy_engine.a"
diff --git a/assets/branding/screensaver.txt b/assets/branding/screensaver.txt
new file mode 100644
index 0000000..47433aa
--- /dev/null
+++ b/assets/branding/screensaver.txt
@@ -0,0 +1,10 @@
+ ▄▄▄
+ ▄█████▄ ▄███████████▄ ▄███████ ▄███████ ▄███████ ▄█ █▄ ▄█ █▄
+███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
+███ ███ ███ ███ ███ ███ ███ ███ ███ ███ █▀ ███ ███ ███ ███
+███ ███ ███ ███ ███ ▄███▄▄▄███ ▄███▄▄▄██▀ ███ ▄███▄▄▄███▄ ███▄▄▄███
+███ ███ ███ ███ ███ ▀███▀▀▀███ ▀███▀▀▀▀ ███ ▀▀███▀▀▀███ ▀▀▀▀▀▀███
+███ ███ ███ ███ ███ ███ ███ ██████████ ███ █▄ ███ ███ ▄██ ███
+███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███ ███
+ ▀█████▀ ▀█ ███ █▀ ███ █▀ ███ ███ ███████▀ ███ █▀ ▀█████▀
+ ███ █▀
diff --git a/assets/fixtures/asymmetric.txt b/assets/fixtures/asymmetric.txt
new file mode 100644
index 0000000..e14d4a9
--- /dev/null
+++ b/assets/fixtures/asymmetric.txt
@@ -0,0 +1,3 @@
+X
+
+ Y
\ No newline at end of file
diff --git a/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt
new file mode 100644
index 0000000..a768787
--- /dev/null
+++ b/assets/fixtures/conversion/alpha-logo.braille.t50.inv0.trim1.txt
@@ -0,0 +1,4 @@
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
\ No newline at end of file
diff --git a/assets/fixtures/conversion/alpha-logo.png b/assets/fixtures/conversion/alpha-logo.png
new file mode 100644
index 0000000..a56f178
Binary files /dev/null and b/assets/fixtures/conversion/alpha-logo.png differ
diff --git a/assets/fixtures/conversion/logo.svg b/assets/fixtures/conversion/logo.svg
new file mode 100644
index 0000000..48d95ec
--- /dev/null
+++ b/assets/fixtures/conversion/logo.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt
new file mode 100644
index 0000000..a768787
--- /dev/null
+++ b/assets/fixtures/conversion/logo.svg.braille.t50.inv0.trim1.txt
@@ -0,0 +1,4 @@
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
\ No newline at end of file
diff --git a/assets/fixtures/conversion/manifest.md b/assets/fixtures/conversion/manifest.md
new file mode 100644
index 0000000..4d79787
--- /dev/null
+++ b/assets/fixtures/conversion/manifest.md
@@ -0,0 +1,21 @@
+# Conversion goldens
+
+Each row is one accepted `(input, mode, threshold, invert, trim)` tuple.
+Omacy’s ascii module must emit the committed `.txt` byte-for-byte.
+
+| Input | Mode | threshold | invert | trim | Expected |
+|---|---|---|---|---|---|
+| `solid-black-on-white.png` | braille | 50 | 0 | 1 | `solid-black-on-white.braille.t50.inv0.trim1.txt` |
+| `solid-black-on-white.png` | block | 50 | 0 | 1 | `solid-black-on-white.block.t50.inv0.trim1.txt` |
+| `solid-black-on-white.png` | braille | 50 | 1 | 1 | `solid-black-on-white.braille.t50.inv1.trim1.txt` |
+| `solid-black-on-white.png` | block | 80 | 0 | 0 | `solid-black-on-white.block.t80.inv0.trim0.txt` |
+| `alpha-logo.png` | braille | 50 | 0 | 1 | `alpha-logo.braille.t50.inv0.trim1.txt` |
+| `logo.svg` | braille | 50 | 0 | 1 | `logo.svg.braille.t50.inv0.trim1.txt` |
+
+Regeneration (manual, reviewed):
+
+```
+cargo run -p omacy-engine --example write_conversion_fixtures
+```
+
+Do not replay live ImageMagick in CI. The Omarchy converter SHA is recorded in [parity.md](../../../docs/parity.md).
diff --git a/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt
new file mode 100644
index 0000000..a2ccd30
--- /dev/null
+++ b/assets/fixtures/conversion/solid-black-on-white.block.t50.inv0.trim1.txt
@@ -0,0 +1,4 @@
+████████
+████████
+████████
+████████
\ No newline at end of file
diff --git a/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt b/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt
new file mode 100644
index 0000000..ddd38d5
--- /dev/null
+++ b/assets/fixtures/conversion/solid-black-on-white.block.t80.inv0.trim0.txt
@@ -0,0 +1,4 @@
+ ████████
+ ████████
+ ████████
+ ████████
\ No newline at end of file
diff --git a/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt
new file mode 100644
index 0000000..a768787
--- /dev/null
+++ b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv0.trim1.txt
@@ -0,0 +1,4 @@
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
\ No newline at end of file
diff --git a/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt
new file mode 100644
index 0000000..8199d25
--- /dev/null
+++ b/assets/fixtures/conversion/solid-black-on-white.braille.t50.inv1.trim1.txt
@@ -0,0 +1,4 @@
+⣿⣿⣿⣿⣿⣿⣿⣿
+⣿⣿ ⣿⣿
+⣿⣿ ⣿⣿
+⣿⣿⣿⣿⣿⣿⣿⣿
\ No newline at end of file
diff --git a/assets/fixtures/conversion/solid-black-on-white.png b/assets/fixtures/conversion/solid-black-on-white.png
new file mode 100644
index 0000000..3b16944
Binary files /dev/null and b/assets/fixtures/conversion/solid-black-on-white.png differ
diff --git a/assets/fonts/FairfaxHD.ttf b/assets/fonts/FairfaxHD.ttf
new file mode 100644
index 0000000..844fc92
Binary files /dev/null and b/assets/fonts/FairfaxHD.ttf differ
diff --git a/assets/fonts/OFL.txt b/assets/fonts/OFL.txt
new file mode 100644
index 0000000..a694297
--- /dev/null
+++ b/assets/fonts/OFL.txt
@@ -0,0 +1,93 @@
+Copyright (c) 2017-2020 Kreative Software.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/crates/omacy-engine/Cargo.toml b/crates/omacy-engine/Cargo.toml
new file mode 100644
index 0000000..1e3cb56
--- /dev/null
+++ b/crates/omacy-engine/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "omacy-engine"
+version = "0.1.0"
+edition = "2021"
+license = "MIT"
+publish = false
+
+[lib]
+name = "omacy_engine"
+crate-type = ["rlib", "staticlib"]
+
+[dependencies]
+libc = "0.2"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+image = { version = "0.25", default-features = false, features = ["png"] }
+resvg = "0.45"
+tiny-skia = "0.11"
+ttfx = { path = "../../vendor/ttfx" }
+
+[dev-dependencies]
+image = { version = "0.25", default-features = false, features = ["png"] }
diff --git a/crates/omacy-engine/examples/write_conversion_fixtures.rs b/crates/omacy-engine/examples/write_conversion_fixtures.rs
new file mode 100644
index 0000000..047d3e3
--- /dev/null
+++ b/crates/omacy-engine/examples/write_conversion_fixtures.rs
@@ -0,0 +1,117 @@
+//! Generate committed conversion goldens. Run from repo root:
+//! `cargo run -p omacy-engine --example write_conversion_fixtures`
+
+use omacy_engine::abi::{OmacyAsciiConfig, OMACY_ASCII_BLOCK, OMACY_ASCII_BRAILLE};
+use omacy_engine::ascii::ascii_from_bytes;
+use std::fs;
+use std::path::PathBuf;
+
+fn repo_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..")
+}
+
+fn solid_png(w: u32, h: u32, bg: [u8; 4], fg: [u8; 4]) -> Vec {
+ let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba(bg));
+ for y in h / 4..h * 3 / 4 {
+ for x in w / 4..w * 3 / 4 {
+ img.put_pixel(x, y, image::Rgba(fg));
+ }
+ }
+ let mut buf = Vec::new();
+ image::DynamicImage::ImageRgba8(img)
+ .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
+ .unwrap();
+ buf
+}
+
+fn alpha_png(w: u32, h: u32) -> Vec {
+ let mut img = image::RgbaImage::from_pixel(w, h, image::Rgba([0, 0, 0, 0]));
+ for y in h / 4..h * 3 / 4 {
+ for x in w / 4..w * 3 / 4 {
+ img.put_pixel(x, y, image::Rgba([255, 255, 255, 255]));
+ }
+ }
+ let mut buf = Vec::new();
+ image::DynamicImage::ImageRgba8(img)
+ .write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
+ .unwrap();
+ buf
+}
+
+fn cfg(mode: u32, width: u32, height: u32, threshold: u8, invert: u8, trim: u8) -> OmacyAsciiConfig {
+ OmacyAsciiConfig {
+ mode,
+ width,
+ height,
+ threshold,
+ invert,
+ trim,
+ _pad: 0,
+ }
+}
+
+fn write_pair(dir: &std::path::Path, stem: &str, bytes: &[u8], c: OmacyAsciiConfig, label: &str) {
+ let text = ascii_from_bytes(c, bytes).expect(label);
+ fs::write(dir.join(format!("{stem}.{label}.txt")), text).unwrap();
+}
+
+fn main() {
+ let dir = repo_root().join("assets/fixtures/conversion");
+ fs::create_dir_all(&dir).unwrap();
+
+ let opaque = solid_png(32, 32, [255, 255, 255, 255], [0, 0, 0, 255]);
+ fs::write(dir.join("solid-black-on-white.png"), &opaque).unwrap();
+
+ let alpha = alpha_png(32, 32);
+ fs::write(dir.join("alpha-logo.png"), &alpha).unwrap();
+
+ let svg = r#""#;
+ fs::write(dir.join("logo.svg"), svg).unwrap();
+
+ write_pair(
+ &dir,
+ "solid-black-on-white",
+ &opaque,
+ cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1),
+ "braille.t50.inv0.trim1",
+ );
+ write_pair(
+ &dir,
+ "solid-black-on-white",
+ &opaque,
+ cfg(OMACY_ASCII_BLOCK, 8, 4, 50, 0, 1),
+ "block.t50.inv0.trim1",
+ );
+ write_pair(
+ &dir,
+ "solid-black-on-white",
+ &opaque,
+ cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 1, 1),
+ "braille.t50.inv1.trim1",
+ );
+ write_pair(
+ &dir,
+ "solid-black-on-white",
+ &opaque,
+ cfg(OMACY_ASCII_BLOCK, 16, 8, 80, 0, 0),
+ "block.t80.inv0.trim0",
+ );
+ write_pair(
+ &dir,
+ "alpha-logo",
+ &alpha,
+ cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1),
+ "braille.t50.inv0.trim1",
+ );
+ write_pair(
+ &dir,
+ "logo.svg",
+ svg.as_bytes(),
+ cfg(OMACY_ASCII_BRAILLE, 8, 4, 50, 0, 1),
+ "braille.t50.inv0.trim1",
+ );
+
+ println!("wrote {}", dir.display());
+}
diff --git a/crates/omacy-engine/include/omacy.h b/crates/omacy-engine/include/omacy.h
new file mode 100644
index 0000000..c43d121
--- /dev/null
+++ b/crates/omacy-engine/include/omacy.h
@@ -0,0 +1,111 @@
+#ifndef OMACY_H
+#define OMACY_H
+
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum {
+ OMACY_OK = 0,
+ OMACY_ERR_NULL = 1,
+ OMACY_ERR_INVALID_ARG = 2,
+ OMACY_ERR_LIMIT = 3,
+ OMACY_ERR_ENGINE = 4,
+ OMACY_ERR_PANIC = 5,
+ OMACY_ERR_DEAD = 6,
+ OMACY_ERR_WRONG_THREAD = 7
+} omacy_status;
+
+#define OMACY_CELL_HAS_BACKGROUND 1
+#define OMACY_CELL_HAS_GLYPH 2
+
+typedef struct {
+ uint32_t glyph;
+ uint8_t fg_r, fg_g, fg_b, fg_a;
+ uint8_t bg_r, bg_g, bg_b, bg_a;
+ uint8_t flags;
+ uint8_t occupancy;
+ uint8_t _pad[2];
+} OmacyCell;
+
+typedef struct {
+ uint32_t cols;
+ uint32_t rows;
+ uint8_t clear_r, clear_g, clear_b, clear_a;
+ uint32_t _pad;
+ const OmacyCell *cells;
+} OmacyFrame;
+
+typedef struct {
+ OmacyFrame frame;
+ uint8_t needs_begin_next;
+ uint8_t _pad[3];
+} OmacyStepResult;
+
+typedef struct {
+ const uint8_t *config_dir;
+ size_t config_dir_len;
+ const uint8_t *ascii;
+ size_t ascii_len;
+ const uint8_t *effect;
+ size_t effect_len;
+ uint8_t bg_r, bg_g, bg_b, bg_a;
+ uint8_t has_seed;
+ uint8_t _pad[3];
+ uint64_t seed;
+} OmacySessionConfig;
+
+typedef struct {
+ const uint8_t *ascii;
+ size_t ascii_len;
+ const uint8_t *effect;
+ size_t effect_len;
+ uint8_t bg_r, bg_g, bg_b, bg_a;
+ uint8_t _pad[3];
+} OmacyPendingConfig;
+
+#define OMACY_ASCII_BRAILLE 0u
+#define OMACY_ASCII_BLOCK 1u
+
+typedef struct {
+ uint32_t mode;
+ uint32_t width;
+ uint32_t height;
+ uint8_t threshold;
+ uint8_t invert;
+ uint8_t trim;
+ uint8_t _pad;
+} OmacyAsciiConfig;
+
+typedef struct OmacySession OmacySession;
+typedef struct OmacyText OmacyText;
+
+omacy_status omacy_session_create(const OmacySessionConfig *cfg,
+ uint32_t cols, uint32_t rows,
+ OmacySession **out);
+omacy_status omacy_session_resize(OmacySession *s, uint32_t cols, uint32_t rows);
+omacy_status omacy_session_step(OmacySession *s, double elapsed_seconds,
+ OmacyStepResult *out);
+omacy_status omacy_session_set_pending_config(OmacySession *s,
+ const OmacyPendingConfig *cfg);
+omacy_status omacy_session_begin_next(OmacySession *s);
+omacy_status omacy_session_generation(const OmacySession *s, uint64_t *out);
+omacy_status omacy_session_error_message(const OmacySession *s, char *buf, size_t buf_len);
+void omacy_session_destroy(OmacySession *s);
+const char *omacy_status_string(omacy_status status);
+
+omacy_status omacy_ascii_from_bytes(const OmacyAsciiConfig *cfg,
+ const uint8_t *bytes, size_t len,
+ OmacyText **out);
+void omacy_text_free(OmacyText *t);
+const char *omacy_text_utf8(const OmacyText *t);
+size_t omacy_text_len(const OmacyText *t);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/crates/omacy-engine/src/abi.rs b/crates/omacy-engine/src/abi.rs
new file mode 100644
index 0000000..f79cb3f
--- /dev/null
+++ b/crates/omacy-engine/src/abi.rs
@@ -0,0 +1,155 @@
+use std::os::raw::c_char;
+
+use ttfx::engine::PackedCell;
+
+pub type OmacyCell = PackedCell;
+
+pub const OMACY_CELL_HAS_BACKGROUND: u8 = ttfx::engine::CELL_HAS_BACKGROUND;
+pub const OMACY_CELL_HAS_GLYPH: u8 = ttfx::engine::CELL_HAS_GLYPH;
+
+pub const OMACY_ASCII_BRAILLE: u32 = 0;
+pub const OMACY_ASCII_BLOCK: u32 = 1;
+
+#[repr(C)]
+#[derive(Clone, Copy, Debug)]
+pub struct OmacyFrame {
+ pub cols: u32,
+ pub rows: u32,
+ pub clear_r: u8,
+ pub clear_g: u8,
+ pub clear_b: u8,
+ pub clear_a: u8,
+ pub _pad: u32,
+ pub cells: *const OmacyCell,
+}
+
+impl OmacyFrame {
+ pub fn zeroed() -> Self {
+ Self {
+ cols: 0,
+ rows: 0,
+ clear_r: 0,
+ clear_g: 0,
+ clear_b: 0,
+ clear_a: 0,
+ _pad: 0,
+ cells: std::ptr::null(),
+ }
+ }
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+pub struct OmacyStepResult {
+ pub frame: OmacyFrame,
+ pub needs_begin_next: u8,
+ pub _pad: [u8; 3],
+}
+
+impl OmacyStepResult {
+ pub fn zeroed() -> Self {
+ Self {
+ frame: OmacyFrame::zeroed(),
+ needs_begin_next: 0,
+ _pad: [0; 3],
+ }
+ }
+}
+
+#[repr(C)]
+pub struct OmacySessionConfig {
+ pub config_dir: *const u8,
+ pub config_dir_len: usize,
+ pub ascii: *const u8,
+ pub ascii_len: usize,
+ pub effect: *const u8,
+ pub effect_len: usize,
+ pub bg_r: u8,
+ pub bg_g: u8,
+ pub bg_b: u8,
+ pub bg_a: u8,
+ pub has_seed: u8,
+ pub _pad: [u8; 3],
+ pub seed: u64,
+}
+
+#[repr(C)]
+pub struct OmacyPendingConfig {
+ pub ascii: *const u8,
+ pub ascii_len: usize,
+ pub effect: *const u8,
+ pub effect_len: usize,
+ pub bg_r: u8,
+ pub bg_g: u8,
+ pub bg_b: u8,
+ pub bg_a: u8,
+ pub _pad: [u8; 3],
+}
+
+#[repr(C)]
+#[derive(Clone, Copy)]
+pub struct OmacyAsciiConfig {
+ pub mode: u32,
+ pub width: u32,
+ pub height: u32,
+ pub threshold: u8,
+ pub invert: u8,
+ pub trim: u8,
+ pub _pad: u8,
+}
+
+#[repr(C)]
+pub struct OmacyText {
+ pub bytes: Vec,
+}
+
+pub unsafe fn slice_ptr_len<'a>(
+ ptr: *const u8,
+ len: usize,
+) -> Result