Я сделал приложение NeonDrift — живые обои для macOS на основе Metal-шейдеров. Для базовой работы не нужны сторонние библиотеки, Screen Recording или Accessibility-доступ. Только AppKit, MetalKit и SwiftUI.
В статье разберу как это устроено изнутри: от трюка с уровнями окон до шейдеров и упаковки в .app. Попутно расскажу про баги, которые я поймал в процессе — растянутую плазму на Retina, крэш при первом же запуске упакованного приложения, анимацию, которая сбрасывалась при каждом переключении Space, и фризы на втором мониторе при смене Space на основном.
Главная идея статьи не в том, чтобы сделать ещё один wallpaper app, а в том, чтобы показать как на macOS можно аккуратно совместить AppKit window management, Metal render loop и SwiftUI-настройки без приватных API — и где именно этот подход начинает трещать по швам.

Идея: не менять обои, а нарисовать поверх рабочего стола
macOS не предоставляет официального API для живых обоев. Но есть обходной путь: создать обычное NSWindow и поместить его рядом с desktop layer — так, чтобы оно визуально работало как фон: не перехватывало клики, не появлялось в Mission Control и не конкурировало с обычными окнами.
Это не exploit: используется публичный API уровней окон — CGWindowLevelForKey(.desktopWindow). Но это всё равно window-level hack, а не официальный wallpaper API. Его нужно тестировать под конкретные версии macOS и режимы рабочего стола: Stage Manager, Spaces, Full Screen — каждый сценарий может вести себя иначе.
Шаг 1: окно на уровне рабочего стола
Вот как выглядит создание “обойного” окна для каждого монитора:
let window = NSWindow(
contentRect: screen.frame,
styleMask: [.borderless],
backing: .buffered,
defer: false,
screen: screen
)
window.backgroundColor = .black
window.isOpaque = true
window.hasShadow = false
window.animationBehavior = .none
window.isReleasedWhenClosed = false
// Без этого окно перехватит все клики по рабочему столу
window.ignoresMouseEvents = true
// Прилипает ко всем Space, не появляется в Mission Control/Exposé
window.collectionBehavior = [
.canJoinAllSpaces,
.stationary,
.ignoresCycle,
.fullScreenAuxiliary // помогает при переходе в/из Full Screen
]
// На практике держит окно над desktop layer, но ниже обычных окон
window.level = NSWindow.Level(
rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1
)
window.setFrame(screen.frame, display: true)
// Поднимаем окно внутри выбранного level без привязки к конкретному окну.
window.order(.above, relativeTo: 0)
Два момента, которые кажутся очевидными, но без которых ничего не работает:
ignoresMouseEvents = true — без этого окно перехватывает все клики по рабочему столу. Я забыл это на первой итерации и провёл несколько минут в недоумении, почему не открываются папки.
.fullScreenAuxiliary в collectionBehavior — без него окно может исчезать или вести себя нестабильно при переходе в/из Full Screen spaces. Оно помогает, но не является гарантией: поведение при возврате из full screen всё равно зависит от версии macOS.
Шаг 2: Metal pipeline и render loop
Для анимации нужен Metal. Сначала — создание устройства, command queue и pipeline:
guard let device = MTLCreateSystemDefaultDevice() else {
throw RuntimeError("Metal недоступен на этом Mac.")
}
guard let commandQueue = device.makeCommandQueue() else {
throw RuntimeError("Не удалось создать command queue.")
}
// Шейдеры грузятся из .metal файлов в бандле как строка исходника,
// а не из default library — потому что default library компилируется
// в момент сборки, а мы хотим грузить шейдеры динамически из ресурсов
let source = try loadShaderSource()
let library = try device.makeLibrary(source: source, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "vs_main")
descriptor.fragmentFunction = library.makeFunction(name: "fs_main")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipelineState = try device.makeRenderPipelineState(descriptor: descriptor)
MTKView получает device, кладётся в window как contentView, и отдаёт отрисовку делегату:
let view = MTKView(frame: NSRect(origin: .zero, size: screen.frame.size))
view.device = device
view.colorPixelFormat = .bgra8Unorm
view.framebufferOnly = true
view.isPaused = false
view.enableSetNeedsDisplay = false
view.preferredFramesPerSecond = 60
window.contentView = view
Рендерер реализует MTKViewDelegate. Помимо draw(in:) нужно реализовать mtkView(_:drawableSizeWillChange:) — это правильная lifecycle-точка для resize, Retina и hotplug:
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
// Точка для пересчёта size-dependent ресурсов.
// У нас ресурсы не зависят от размера — resolution передаётся
// через uniforms каждый кадр. Но метод нужен для корректного lifecycle.
}
Весь рисунок происходит в draw(in:):
func draw(in view: MTKView) {
guard
let descriptor = view.currentRenderPassDescriptor,
let drawable = view.currentDrawable,
let buffer = commandQueue.makeCommandBuffer(),
let encoder = buffer.makeRenderCommandEncoder(descriptor: descriptor)
else { return }
var uniforms = Uniforms(
time: Float(CACurrentMediaTime() - startTime) * animationSpeed,
resolution: SIMD2(Float(view.drawableSize.width),
Float(view.drawableSize.height)),
// ... остальные параметры темы
)
encoder.setRenderPipelineState(pipelineState)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)
encoder.endEncoding()
buffer.present(drawable)
buffer.commit()
}
Про drawableSize vs bounds: первую версию шейдера я написал с view.bounds.size — и получил растянутую плазму на Retina. bounds возвращает размер в points, а in.position.xy во фрагментном шейдере — физические пиксели. На Retina-дисплее разница 2×, картинка сжималась в левый нижний угол и растягивалась по viewport. view.drawableSize возвращает физические пиксели — после замены всё встало на место.
Про setFragmentBytes: удобен для небольшого uniforms-блока (у нас ~120 байт). Если добавить массивы или историю состояний — лучше перейти на MTLBuffer.
Шаг 3: шейдер — вся картинка во фрагментной функции
Вертексный шейдер тривиален — один треугольник на весь экран:
vertex VertexOut vs_main(uint vertexID [[vertex_id]]) {
float2 positions[3] = {
float2(-1.0, -1.0),
float2( 3.0, -1.0),
float2(-1.0, 3.0),
};
VertexOut out;
out.position = float4(positions[vertexID], 0.0, 1.0);
return out;
}
Вся логика картинки — во фрагментном шейдере. Пример простой плазмы:
fragment float4 fs_main(VertexOut in [[stage_in]],
constant Uniforms &u [[buffer(0)]]) {
// in.position.xy — физические пиксели, u.resolution — тоже физические пиксели
float2 uv = in.position.xy / u.resolution;
float2 p = uv * 2.0 - 1.0;
p.x *= u.resolution.x / u.resolution.y;
float t = u.time * 0.4;
float v = sin(p.x * 3.0 + t)
+ sin(p.y * 2.5 - t * 0.7)
+ sin((p.x + p.y) * 2.0 + t * 1.3)
+ sin(length(p) * 4.0 - t * 2.0);
v = v * 0.25 + 0.5;
return float4(palette(v, u.palettePreset), 1.0);
}
Это классический подход для процедурной графики — так устроен Shadertoy. Вместо геометрии рисуем один треугольник, шейдер сам вычисляет цвет каждого пикселя.

Шаг 4: плавные переходы между темами
Мы передаём в шейдер два набора параметров (текущий и предыдущий) и значение transitionProgress от 0 до 1:
var themeTransitionProgress: Float {
let elapsed = CACurrentMediaTime() - transitionStartTime
let progress = min(max(elapsed / 0.7, 0), 1)
return Float(1 - pow(1 - progress, 3)) // ease-out cubic
}
float3 colorA = renderTheme(params_current, uv, u);
float3 colorB = renderTheme(params_previous, uv, u);
float3 color = mix(colorB, colorA, u.transitionProgress);
Кросс-фейд 0.7 секунды с кубической кривой замедления. Работает между любыми двумя темами, включая переходы между family (плазма → фракталы).
Шаг 5: несколько мониторов и баг с анимацией
При подключении / отключении монитора macOS отправляет NSApplication.didChangeScreenParametersNotification:
@objc private func handleScreenConfigurationChange() {
// Задержка нужна — без неё NSScreen.screens ещё не обновился
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.refreshDisplaysAndWallpaperWindows()
}
}
Перед пересозданием рендереры сохраняют состояние — время старта анимации, эпоха Мандельброта и т.п. Это важно: без этого при каждом hotplug анимация начинается сначала.
Похожая проблема вылезла со Spaces. В первой версии collectionBehavior не включал .stationary, и при переключении между рабочими столами окна пересоздавались заново — анимация сбрасывалась на каждый свитч. Фикс простой, но симптом неочевидный: кажется что “обои мигают при переключении Space”.
На каждый монитор — отдельное окно и отдельный рендерер. В текущей реализации каждый рендерер сам создаёт command queue и pipeline.
Есть нерешённая проблема: при подключённых двух мониторах, если переключить Space на основном, рендерер на втором мониторе начинает заметно тормозить — FPS падает, анимация дёргается. Похоже, macOS снижает приоритет render loop для desktop-layer окон на дисплеях, которые не вовлечены в текущий Space-переход. Workaround пока не нашёл — это поведение системы, а не баг в коде рендерера. Это проще, но не оптимально: MTLDevice и pipeline state можно вынести в общий MetalContext, а на рендерер оставить только состояние конкретного экрана — command queue, uniforms, тайминги.
Шаг 6: настройки и SwiftUI UI

AppKit отвечает за системное поведение окон, SwiftUI — за настройки, Metal — за постоянный рендер. Для панели настроек — NavigationSplitView с боковой панелью и областью деталей. Стейт в WallpaperSettingsStore — ObservableObject, данные в UserDefaults через JSON.
Предпросмотр темы прямо в настройках — это NSViewRepresentable с полноценным MTKView и отдельным рендерером, который работает независимо от “боевых” окон на рабочем столе.

Шаг 7: запуск при входе в систему
В macOS 13+ есть SMAppService:
try SMAppService.mainApp.register() // включить
try SMAppService.mainApp.unregister() // выключить
Требует подписанного .app bundle. В dev-сборке через swift run не работает — только после упаковки. При первом включении macOS 14 показывает prompt в системных настройках — пользователь должен явно подтвердить.
Шаг 8: пауза при Low Power Mode
NotificationCenter.default.addObserver(
self,
selector: #selector(handlePowerStateChange),
name: NSProcessInfo.powerStateDidChangeNotification,
object: ProcessInfo.processInfo
)
private func applyPowerPolicy() {
let shouldPause = preferences.pauseOnLowPowerMode
&& ProcessInfo.processInfo.isLowPowerModeEnabled
for renderer in renderers.values {
renderer.setPaused(shouldPause, reason: "Low Power Mode")
}
}
view.isPaused = true останавливает render loop: приложение перестаёт отправлять новые кадры на GPU. Аналогично делаем при willSleepNotification / screensDidSleepNotification.
Шаг 9: упаковка в .app без Xcode
SwiftPM не создаёт .app bundle автоматически. Нужен shell-скрипт:
#!/usr/bin/env bash
set -euo pipefail
APP_NAME="NeonDrift"
BUNDLE_ID="com.yourname.neon-drift"
VERSION="0.1.0"
APP_DIR="$APP_NAME.app/Contents"
swift build -c release
mkdir -p "$APP_DIR/MacOS" "$APP_DIR/Resources"
cp ".build/release/$APP_NAME" "$APP_DIR/MacOS/"
cp -r ".build/release/${APP_NAME}_${APP_NAME}.bundle" "$APP_DIR/Resources/"
cat > "$APP_DIR/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
<key>CFBundleIdentifier</key> <string>$BUNDLE_ID</string>
<key>CFBundleShortVersionString</key> <string>$VERSION</string>
<key>LSMinimumSystemVersion</key> <string>14.0</string>
<key>NSHighResolutionCapable</key> <true/>
</dict>
</plist>
EOF
Bundle.module: крэш при первом запуске упакованного приложения
Первый же запуск упакованного .app закончился крэшем при старте. В консоли — assertionFailure из недр SPM. SPM-сгенерированный resource accessor рассчитывает найти бандл рядом с исполняемым, как это работает в .build/. После упаковки бандл лежит в Contents/Resources/ — accessor его не находит.
Пришлось написать собственный локатор, который проверяет оба места:
enum ShaderBundleLocator {
static var shaderDirectoryURL: URL? {
let bundleName = "NeonDrift_NeonDrift.bundle"
// Упакованный .app: Contents/Resources/
if let resourcesURL = Bundle.main.resourceURL {
let url = resourcesURL.appendingPathComponent(bundleName)
if let b = Bundle(url: url) { return b.resourceURL }
}
// SPM dev-сборка: рядом с исполняемым
let url = Bundle.main.bundleURL.appendingPathComponent(bundleName)
if let b = Bundle(url: url) { return b.resourceURL }
return Bundle.main.resourceURL
}
}
После этого и swift run, и упакованный .app работают одинаково.
Производительность

Замерял на MacBook Pro M4 Pro 24 GB, встроенный дисплей 1512×982 pt (Retina 2×, фактически 3024×1964 px), macOS Sequoia 15.4, один монитор, Activity Monitor → GPU History.
Сценарий | GPU | CPU |
|---|---|---|
Плазма / Паттерны, 60 FPS | ~9-17% | ~8-15% |
Фракталы (Мандельброт), 60 FPS | ~9–23% | ~8–17% |
Любая тема, 30 FPS | примерно на 2-3 процента меньше | также примерно на 2-3 процента меньше |
Пауза (Low Power Mode) | 0% | 0% |
Фракталы тяжелее плазмы — больше итераций на пиксель. На два монитора нагрузка растёт почти пропорционально суммарному числу пикселей, так как работают два независимых render loop. Цифры сильно зависят от thermal state: под длительной нагрузкой MacBook может троттлить, поэтому GPU load и стабильность FPS будут меняться.
Что реально не сработало (и почему)
SceneKit / SpriteKit. Первая мысль была — взять SceneKit, добавить SCNPlane, кинуть на него шейдер. Я потратил день на это, получил рабочий прототип, потом выкинул. Не потому что SceneKit плохой — а потому что мне нужен ровно один fullscreen quad и один render pass. SceneKit тащит за собой граф сцены, менеджер ресурсов, физику. Это как ехать за хлебом на грузовике.
ScreenSaverView. Есть ScreenSaver API: ScreenSaverView, configureSheet, деплой через System Settings. Я проверил — это работает именно как заставка, не как постоянный фон. ScreenSaver деактивируется при любой активности пользователя. Не то.
Bundle.module. Описано выше. Симптом мерзкий — assertionFailure без внятного сообщения об ошибке, только адрес в стеке. Я минут 20 думал что сломал линковку.
App Store. Пробовал подготовить сборку для MAS. В моей попытке sandbox-окружение сломало ожидаемое поведение desktop-layer окна: оно либо не вставало на нужный уровень, либо вело себя нестабильно. Возможно, это решается другой конфигурацией entitlements или collectionBehavior — я не стал превращать это в отдельное расследование и пока оставил прямую дистрибуцию.
Совместимость: что я проверил
Сценарий | Результат |
|---|---|
macOS 14, один монитор | Работает |
macOS 15, один монитор | Работает |
Два монитора, hotplug | Работает, анимация сохраняется |
Два монитора, смена Space на основном | Фризы на втором мониторе — не решено |
Mission Control | Окна не видны — как и должно быть |
Переключение Spaces | Работает, анимация не сбрасывается |
Full Screen app → выход | Иногда артефакт порядка окон на ~0.3 сек |
Stage Manager включён | Работает, но не тестировал всесторонне |
Sleep → Wake | Работает, пересоздаёт окна автоматически |
Low Power Mode | Рендер паузится, возобновляется при выходе |
Stage Manager протестирован только на базовых сценариях: переключение окон, Mission Control и возврат из Full Screen. Сложные комбинации — несколько дисплеев с разными Space на каждом — я не проверял.
Архитектура целиком
AppDelegate
├── refreshDisplaysAndWallpaperWindows() — окно на каждый NSScreen
├── PlasmaRenderer (MTKViewDelegate) — один на монитор
│ ├── init(view:) — device, commandQueue, pipeline
│ ├── loadShaderSource() — .metal из бандла → строка
│ ├── draw(in:) — uniforms → encoder → present
│ ├── mtkView(_:drawableSizeWillChange:) — resize/Retina/hotplug
│ └── apply(configuration:) — тема + transition
├── WallpaperSettingsStore (ObservableObject)
│ ├── UserDefaults (JSON) — персистентность
│ ├── SMAppService — login item
│ └── callbacks → AppDelegate
└── WallpaperSettingsView (SwiftUI)
├── NavigationSplitView
├── WallpaperPreviewView (NSViewRepresentable + MTKView)
└── ConfigurationEditorCard
Production-нюансы
drawableSize, неbounds— иначе на Retina получите растянутую картинку в левом нижнем углу.Не игнорируйте
mtkView(_:drawableSizeWillChange:): даже если сейчас ресурсы не зависят от размера, это правильная точка для будущей resize/Retina/hotplug-логики.FPS configurable: 30/60/120. 120 имеет смысл только на дисплеях с высокой частотой обновления; на обычных 60 Hz это просто лишняя нагрузка без видимого эффекта.
Паузить при Low Power Mode, sleep, и опционально — при работе от батареи.
После sleep
currentDrawableможет бытьnilнесколько кадров — guard вdraw(in:)обязателен.MTLDeviceи pipeline state можно шарить между несколькими рендерерами — создание на каждый монитор это лишние ресурсы (в текущей реализации не оптимизировано).
Итог
Живые обои на macOS — это в первую очередь window-level hack: NSWindow с уровнем CGWindowLevelForKey(.desktopWindow) + 1, ignoresMouseEvents, правильный collectionBehavior. Дальше — Metal render loop поверх MTKView, фрагментный шейдер который считает цвет каждого пикселя из времени и математики, и немного AppKit-клея для реакции на смену мониторов, sleep/wake и Low Power Mode.
Весь код — около 2600 строк Swift и ~1200 строк Metal. Никаких внешних зависимостей. macOS 14+ — это ограничение конкретной реализации, не самого подхода.
Исходники: github.com/maxches99/NeonDrift
























