// KuroMetalApp — standalone Metal screensaver app. The robust macOS-26 path:
// borderless windows per display at screen-saver level hosting the shared Core
// renderer, dismissed on input. No legacyScreenSaver host → no instance stacking,
// no missing stopAnimation, real live preview, working selection.
//
// (no args) → config window (settings + live preview)
// --agent → background idle watcher (menu-bar); auto-activates on idle
// --screensaver → fullscreen now from saved settings; any input quits
// --wallpaper → animated DESKTOP BACKGROUND (behind icons, click-through, menu-bar;
// battery-aware power policy). Persist via "Beim Login starten".
// --scene/--preset/--intensity → fullscreen override (testing)
import AppKit
import Metal
final class AppDelegate: NSObject, NSApplicationDelegate {
enum Mode { case config, agent, oneShot, wallpaper }
private let mode: Mode
private let override: (settings: Settings, autoCycle: Bool)?
private var config: ConfigWindowController?
private var statusItem: NSStatusItem?
private var idleTimer: Timer?
private var saverWindows: [NSWindow] = []
private var saverViews: [MetalHostView] = []
private var monitors: [Any] = []
// Wallpaper mode (desktop background)
private var wallpaperWindows: [NSWindow] = []
private var wallpaperViews: [MetalHostView] = []
private var power: PowerPolicy?
private var screenRebuildItem: DispatchWorkItem?
private var startTime = CACurrentMediaTime()
private var active = false
private var sceneHotkeyEnabled = false // ←/→ scene switch: deliberate fullscreen only
private var quitOnLastWindowClosed = false // config mode: closing the window quits the app
private var synth: Synth?
init(mode: Mode, override: (Settings, Bool)?) {
self.mode = mode
self.override = override
}
func applicationDidFinishLaunching(_ note: Notification) {
buildMainMenu()
switch mode {
case .oneShot:
startScreensaver()
case .config:
quitOnLastWindowClosed = true // settings UI: closing the window quits (macOS standard)
let c = ConfigWindowController(); config = c
c.showWindow(nil); NSApp.activate(ignoringOtherApps: true)
case .agent:
setupStatusItem()
idleTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { [weak self] _ in self?.tickIdle() }
case .wallpaper:
startWallpaper()
}
}
// MARK: - wallpaper (animated desktop background)
/// Run the live render as a desktop background: a borderless window per screen at
/// desktop level (above the static wallpaper, below the icons), click-through, with
/// a battery-aware power policy. No input-dismiss — it just lives there.
private func startWallpaper() {
buildWallpaperWindows()
let pm = PowerPolicy()
pm.onChange = { [weak self] in self?.applyPowerPolicy() }
pm.start(); power = pm
applyPowerPolicy()
NotificationCenter.default.addObserver(self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil)
setupWallpaperStatusItem()
}
private func buildWallpaperWindows() {
wallpaperViews.forEach { $0.stop() }; wallpaperViews.removeAll()
wallpaperWindows.forEach { $0.orderOut(nil) }; wallpaperWindows.removeAll()
let (s, cycle) = override ?? AppSettings.make()
let cycleSec = cycle ? AppSettings.cycleMinutes * 60 : 0
for screen in NSScreen.screens {
let win = NSWindow(contentRect: screen.frame, styleMask: [.borderless],
backing: .buffered, defer: false, screen: screen)
// One above the static wallpaper picture, below the desktop icons.
win.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1)
win.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle, .fullScreenNone]
win.ignoresMouseEvents = true // clicks pass through to the desktop/icons
win.backgroundColor = .black; win.isOpaque = true; win.hasShadow = false
win.isReleasedWhenClosed = false
win.setFrame(screen.frame, display: true)
let view = MetalHostView(frame: NSRect(origin: .zero, size: screen.frame.size),
settings: s, autoCycleSec: cycleSec)
view.autoresizingMask = [.width, .height]
view.setRenderScale(CGFloat(AppSettings.wallpaperRenderScale))
win.contentView = view
win.orderFrontRegardless()
view.start()
view.renderPrimingFrame() // one frame BEFORE any policy pause: a frozen
// wallpaper must show an image, not black
wallpaperWindows.append(win); wallpaperViews.append(view)
}
}
@objc private func screensChanged() {
// Coalesce the burst of notifications on sleep/wake/hot-plug.
screenRebuildItem?.cancel()
let item = DispatchWorkItem { [weak self] in self?.buildWallpaperWindows(); self?.applyPowerPolicy() }
screenRebuildItem = item
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: item)
}
private func applyPowerPolicy() {
guard let power else { return }
for (win, v) in zip(wallpaperWindows, wallpaperViews) {
var i = RenderPolicyInputs()
// NOT win.occlusionState: AppKit never reports `.visible` for a window at
// the desktop level, so this read was permanently true and the wallpaper
// paused before its first frame (2026-08-19 black-desktop regression).
i.occluded = isDesktopCovered(by: power.windows, screen: win.frame)
i.screenLocked = power.screenLocked
i.screensAsleep = power.screensAsleep
i.onBattery = power.onBattery
i.lowPowerMode = power.lowPowerMode
i.thermalSerious = power.thermal == .serious
i.thermalCritical = power.thermal == .critical
i.animateOnBattery = AppSettings.wallpaperOnBattery
switch renderState(i) {
case .hidden, .frozen: v.setPaused(true)
case .animating(let fps): v.setPaused(false); v.setFrameCap(fps)
}
}
}
/// "Als Hintergrund" from the config window: host the wallpaper IN-PROCESS (so there
/// is no separate-process flash) and persist it for the next login WITHOUT launching
/// a second instance now. Re-applies the latest settings if already hosting.
func setWallpaperFromConfig() {
if power == nil { startWallpaper() } // first time: windows + power + status item + screen observer
else { buildWallpaperWindows(); applyPowerPolicy() } // re-apply with the new settings
LoginItem.enableWallpaper(bootstrapNow: false)
NSApp.setActivationPolicy(.accessory)
}
private func setupWallpaperStatusItem() {
let item = NSStatusItem.local()
item.button?.title = "▦"
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Einstellungen…", action: #selector(menuConfig), keyEquivalent: ""))
let login = NSMenuItem(title: "Beim Login starten", action: #selector(toggleWallpaperLogin), keyEquivalent: "")
login.state = LoginItem.wallpaperEnabled ? .on : .off
menu.addItem(login)
let batt = NSMenuItem(title: "Auf Batterie animieren", action: #selector(toggleWallpaperBattery), keyEquivalent: "")
batt.state = AppSettings.wallpaperOnBattery ? .on : .off
menu.addItem(batt)
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Hintergrund beenden", action: #selector(quitWallpaper), keyEquivalent: ""))
menu.items.forEach { $0.target = self }
item.menu = menu
statusItem = item
}
/// Live-apply a changed wallpaper render scale from the config UI (no rebuild).
func refreshWallpaperScale() {
wallpaperViews.forEach { $0.setRenderScale(CGFloat(AppSettings.wallpaperRenderScale)) }
}
/// Remove the wallpaper: disable the KeepAlive LaunchAgent first (else launchd would
/// immediately relaunch the process), then terminate.
@objc private func quitWallpaper() {
LoginItem.disableWallpaper()
NSApp.terminate(nil)
}
@objc private func toggleWallpaperLogin(_ sender: NSMenuItem) {
if LoginItem.wallpaperEnabled { LoginItem.disableWallpaper(); sender.state = .off }
else { LoginItem.enableWallpaper(); sender.state = .on }
}
@objc private func toggleWallpaperBattery(_ sender: NSMenuItem) {
AppSettings.wallpaperOnBattery.toggle()
sender.state = AppSettings.wallpaperOnBattery ? .on : .off
applyPowerPolicy()
}
// MARK: - main menu (standard macOS app menu: About / Settings / Quit)
private func buildMainMenu() {
let main = NSMenu()
let appItem = NSMenuItem(); main.addItem(appItem)
let appMenu = NSMenu(); appItem.submenu = appMenu
let about = appMenu.addItem(withTitle: "Über Kuro Screensaver", action: #selector(showAbout), keyEquivalent: "")
about.target = self
appMenu.addItem(.separator())
let settings = appMenu.addItem(withTitle: "Einstellungen…", action: #selector(menuConfig), keyEquivalent: ",")
settings.target = self
let start = appMenu.addItem(withTitle: "Vollbild starten", action: #selector(menuStart), keyEquivalent: "s")
start.target = self
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Kuro Screensaver ausblenden", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Kuro Screensaver beenden", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
// a minimal Window menu so the config window has standard controls
let winItem = NSMenuItem(); main.addItem(winItem)
let winMenu = NSMenu(title: "Fenster"); winItem.submenu = winMenu
winMenu.addItem(withTitle: "Minimieren", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
winMenu.addItem(withTitle: "Schließen", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
NSApp.windowsMenu = winMenu
NSApp.mainMenu = main
}
@objc private func showAbout() { NSApp.orderFrontStandardAboutPanel(nil) }
// MARK: - agent idle watching
private func setupStatusItem() {
let item = NSStatusItem.local()
item.button?.title = "▦"
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "Jetzt starten", action: #selector(menuStart), keyEquivalent: ""))
menu.addItem(NSMenuItem(title: "Einstellungen…", action: #selector(menuConfig), keyEquivalent: ""))
menu.addItem(.separator())
menu.addItem(NSMenuItem(title: "Kuro Screensaver beenden", action: #selector(menuQuit), keyEquivalent: ""))
menu.items.forEach { $0.target = self }
item.menu = menu
statusItem = item
}
private func tickIdle() {
guard !active else { return }
if systemIdleSeconds() >= AppSettings.idleMinutes * 60 { startScreensaver() }
}
@objc private func menuStart() { startScreensaver() }
@objc private func menuConfig() { showConfig() }
@objc private func menuQuit() { NSApp.terminate(nil) }
private func showConfig() {
if config == nil { config = ConfigWindowController() }
NSApp.setActivationPolicy(.regular) // give the agent a normal window/dock while config is open
config?.showWindow(nil)
config?.window?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
/// Re-opening the app (Dock click / `open`) while an instance is already
/// running (e.g. the idle agent) → show the config window.
func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
if !active { showConfig() }
return true
}
/// Config mode is a settings UI: closing its window quits the app (like System
/// Settings). Agent/wallpaper run window-less in the menu bar, so the flag stays
/// false there and a temporarily-opened config window closing won't quit them.
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
quitOnLastWindowClosed
}
// MARK: - fullscreen show / dismiss
func startScreensaver(returnToConfig: Bool = false) {
guard !active else { return }
active = true
if returnToConfig { config?.pausePreview(); config?.window?.orderOut(nil) }
let (s, cycle) = override ?? AppSettings.make()
let cycleSec = cycle ? AppSettings.cycleMinutes * 60 : 0
for screen in NSScreen.screens {
let win = NSWindow(contentRect: screen.frame, styleMask: [.borderless],
backing: .buffered, defer: false, screen: screen)
win.level = NSWindow.Level(Int(CGShieldingWindowLevel()))
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
win.backgroundColor = .black; win.isOpaque = true; win.hasShadow = false
win.setFrame(screen.frame, display: true) // exact per-screen placement
let view = MetalHostView(frame: NSRect(origin: .zero, size: screen.frame.size),
settings: s, autoCycleSec: cycleSec)
view.autoresizingMask = [.width, .height]
win.contentView = view
win.orderFrontRegardless() // shows on non-main screens too
view.start()
saverWindows.append(win); saverViews.append(view)
}
NSApp.activate(ignoringOtherApps: true)
NSCursor.hide()
if AppSettings.sound { let sy = Synth(); sy.start(); synth = sy }
startTime = CACurrentMediaTime()
installInputMonitors()
if returnToConfig { dismissTarget = .config }
else if mode == .oneShot { dismissTarget = .terminate }
else { dismissTarget = .resume }
// Scene-switch hotkey in any deliberately-launched fullscreen (config preview/
// menu or --screensaver) — works whether or not film mode is auto-advancing, so
// the user can browse scenes without ending the animation. Only the idle-agent
// activation is excluded (there any input must wake/dismiss the screensaver).
sceneHotkeyEnabled = (mode != .agent)
}
private enum DismissTarget { case config, resume, terminate }
private var dismissTarget: DismissTarget = .resume
private func dismissScreensaver() {
guard active else { return }
active = false
monitors.forEach { NSEvent.removeMonitor($0) }; monitors.removeAll()
saverViews.forEach { $0.stop() }; saverViews.removeAll()
saverWindows.forEach { $0.orderOut(nil) }; saverWindows.removeAll()
synth?.stop(); synth = nil
NSCursor.unhide()
switch dismissTarget {
case .terminate: NSApp.terminate(nil)
case .config:
config?.resumePreview()
config?.window?.makeKeyAndOrderFront(nil); NSApp.activate(ignoringOtherApps: true)
case .resume:
break // agent keeps watching; idle has reset due to the input
}
}
private func installInputMonitors() {
let down: (NSEvent) -> Void = { [weak self] _ in self?.dismissScreensaver() }
let moved: (NSEvent) -> Void = { [weak self] _ in
guard let self, CACurrentMediaTime() - self.startTime > 1.2 else { return }
self.dismissScreensaver()
}
// ←/→ cycle scenes instead of dismissing — only when enabled (deliberate fullscreen).
// Returns true if the key was a scene-switch hotkey (event consumed, saver stays up).
let sceneKey: (NSEvent) -> Bool = { [weak self] e in
guard let self, self.sceneHotkeyEnabled, e.type == .keyDown, !e.isARepeat else { return false }
switch e.keyCode {
case 124: self.saverViews.forEach { $0.cycleScene(by: 1) }; return true // → next
case 123: self.saverViews.forEach { $0.cycleScene(by: -1) }; return true // ← previous
default: return false
}
}
monitors.append(NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown, .otherMouseDown, .scrollWheel]) { e in
if sceneKey(e) { return nil } // consumed: switched scene, don't dismiss
down(e); return nil
} as Any)
monitors.append(NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved]) { e in moved(e); return e } as Any)
monitors.append(NSEvent.addGlobalMonitorForEvents(matching: [.keyDown, .leftMouseDown, .rightMouseDown, .mouseMoved]) { e in
if e.type == .mouseMoved { moved(e) } else { down(e) } // scene-switch is local-monitor only
} as Any)
}
}
private extension NSStatusItem {
static func local() -> NSStatusItem { NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) }
}
// ---- arg parsing ----
func parseOverride() -> (Settings, Bool)? {
let args = CommandLine.arguments
guard args.contains("--scene") || args.contains("--preset") || args.contains("--intensity") else { return nil }
var s = Settings(); var sceneArg = "random"
var it = args.dropFirst().makeIterator()
while let k = it.next() {
switch k {
case "--scene": sceneArg = it.next() ?? sceneArg
case "--preset": s.presetID = it.next() ?? s.presetID
case "--intensity": s.crtIntensity = Float(it.next() ?? "") ?? s.crtIntensity
default: break
}
}
let cycle = (sceneArg == "random")
s.scene = (sceneArg == "random" || !SceneRegistry.ids.contains(sceneArg))
? (SceneRegistry.ids.randomElement() ?? "terrain") : sceneArg
return (s, cycle)
}
let override = parseOverride()
let mode: AppDelegate.Mode =
CommandLine.arguments.contains("--wallpaper") ? .wallpaper :
CommandLine.arguments.contains("--agent") ? .agent :
(CommandLine.arguments.contains("--screensaver") || override != nil) ? .oneShot : .config
let app = NSApplication.shared
let delegate = AppDelegate(mode: mode, override: override)
app.delegate = delegate
app.setActivationPolicy(mode == .config ? .regular : .accessory)
app.run()