From 811f004e9337e0602d4901c46c634354287a95ed Mon Sep 17 00:00:00 2001 From: Andreas Bielawski Date: Fri, 12 Jun 2026 18:54:15 +0200 Subject: [PATCH 1/2] Keep fullscreen apps active in background Since macOS 15, runningboardd suspends Catalyst apps as soon as their scene becomes invisible (window on another Space, minimized, hidden), freezing PlayCover games completely. Games also pause themselves because the engine receives the standard background lifecycle events. This commit does the following on macOS >= 15: 1. Plays an inaudible audio loop (AVAudioEngine, .playback + .mixWithOthers, output volume 0). Like on iOS, an app that is actively playing audio is exempt from suspension. Requires UIBackgroundModes: [audio] in the app's Info.plist. 2. Hides the background transition from the game: drops the willResignActive/didEnterBackground notifications and neuters the corresponding app/scene delegate callbacks, so engines such as Unity never pause their player loop. Because the shutdown sequence replays exactly these suppressed events (quitWhenClose does so explicitly, Cmd+Q via the system), all quit paths are hooked through NSApplication.terminate to stand down the suppression and the audio exemption first, so games can still save their state and exit cleanly. Verified with Genshin Impact, Honkai: Star Rail and ZZZ on macOS 27 Beta: the game keeps running at full speed while its window is swiped away, and quits cleanly via both the close button and Cmd+Q. Fixes https://github.com/PlayCover/PlayCover/issues/1568 Co-Authored-By: Claude Fable 5 --- AKPlugin.swift | 20 ++++ PlayTools/PlayCover.swift | 203 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 223 insertions(+) diff --git a/AKPlugin.swift b/AKPlugin.swift index b0ebfece..b98632fb 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -21,6 +21,7 @@ private struct AKAppSettingsData: Codable { class AKPlugin: NSObject, Plugin { required override init() { super.init() + Self.hookTermination() if let window = NSApplication.shared.windows.first { window.styleMask.insert([.resizable]) window.collectionBehavior = [.fullScreenPrimary, .managed, .participatesInCycle] @@ -287,6 +288,25 @@ class AKPlugin: NSObject, Plugin { NSMenu.setMenuBarVisible(visible) } + // All quit paths (Cmd+Q, menu, Dock, the close-button handler in + // PlayTools) funnel through NSApplication.terminate. Announce the + // termination so the background keep-alive in PlayTools can stand down + // and let the shutdown lifecycle reach the app again. Posting the + // notification is a no-op when nobody listens. + private static func hookTermination() { + let selector = #selector(NSApplication.terminate(_:)) + guard let method = class_getInstanceMethod(NSApplication.self, selector) else { return } + typealias TerminateFn = @convention(c) (NSApplication, Selector, AnyObject?) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: TerminateFn.self) + let block: @convention(block) (NSApplication, AnyObject?) -> Void = { app, sender in + NotificationCenter.default.post( + name: Notification.Name("io.playcover.PlayTools.applicationWillTerminate"), + object: nil) + original(app, selector, sender) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } + /// Convenience instance property that exposes the cached static preference. private var hideTitleBarSetting: Bool { Self.akAppSettingsData?.hideTitleBar ?? false } private var floatingWindowSetting: Bool { Self.akAppSettingsData?.floatingWindow ?? false } diff --git a/PlayTools/PlayCover.swift b/PlayTools/PlayCover.swift index 2040dab3..50ce71da 100644 --- a/PlayTools/PlayCover.swift +++ b/PlayTools/PlayCover.swift @@ -5,6 +5,8 @@ import Foundation import UIKit +import AVFoundation +import os public class PlayCover: NSObject { @@ -18,6 +20,12 @@ public class PlayCover: NSObject { PlayInput.shared.initialize() DiscordIPC.shared.initialize() + // runningboardd only freezes invisible scenes since macOS 15. + if ProcessInfo.processInfo.isOperatingSystemAtLeast( + OperatingSystemVersion(majorVersion: 15, minorVersion: 0, patchVersion: 0)) { + BackgroundKeepAlive.shared.start() + } + if PlaySettings.shared.rootWorkDir { // Change the working directory to / just like iOS FileManager.default.changeCurrentDirectoryPath("/") @@ -55,6 +63,11 @@ public class PlayCover: NSObject { queue: OperationQueue.main ) { notif in if PlayScreen.shared.nsWindow?.isEqual(notif.object) ?? false { + // The steps below replay exactly the lifecycle events the + // background keep-alive suppresses; let them through again + // so the app can save its state before terminating. + BackgroundKeepAlive.shared.prepareForTermination() + // Step 1: Resign active for scene in UIApplication.shared.connectedScenes { scene.delegate?.sceneWillResignActive?(scene) @@ -106,3 +119,193 @@ public class PlayCover: NSObject { DispatchQueue.main.asyncAfter(deadline: when, execute: closure) } } + +// Since macOS 15, runningboardd freezes iOS apps as soon as their scene is +// no longer visible. Like on iOS, an app that is actively playing audio is +// exempt, so keep an inaudible player running for the whole app lifetime. +// Requires "UIBackgroundModes: [audio]" in the app's Info.plist. +class BackgroundKeepAlive { + static let shared = BackgroundKeepAlive() + static let log = Logger(subsystem: "io.playcover.PlayTools", category: "BackgroundKeepAlive") + + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private var silence: AVAudioPCMBuffer? + + // The OS legitimately moves the scene to background when it becomes + // invisible (the signal does not come from NSWindow.occlusionState, so it + // cannot be spoofed there). Games then pause themselves per the iOS + // lifecycle convention. Audio playback already prevents the OS-side + // suspension, so it is sufficient to hide the background transition from + // the app: drop the lifecycle notifications and neuter the corresponding + // app/scene delegate callbacks. + // Name is shared with AKInterface, which posts it from its + // NSApplication.terminate hook before the actual termination starts. + static let willTerminateNotification = + Notification.Name("io.playcover.PlayTools.applicationWillTerminate") + + // Cleared once termination starts: the shutdown sequence replays exactly + // the suppressed lifecycle events (resign active, enter background) so the + // game can save its state — from that point on they must reach the app. + private static var suppressionActive = true + private var terminating = false + + private static let suppressedNotifications: Set = [ + UIApplication.willResignActiveNotification.rawValue, + UIApplication.didEnterBackgroundNotification.rawValue, + UIScene.willDeactivateNotification.rawValue, + UIScene.didEnterBackgroundNotification.rawValue + ] + + private func suppressBackgroundLifecycle() { + Self.installPostFilter(NSSelectorFromString("postNotificationName:object:userInfo:")) + Self.installPostFilter(NSSelectorFromString("postNotification:")) + + NotificationCenter.default.addObserver(forName: UIScene.willConnectNotification, + object: nil, queue: .main) { notif in + guard let scene = notif.object as? UIScene, let delegate = scene.delegate else { return } + Self.neuter(type(of: delegate), "sceneDidEnterBackground:") + Self.neuter(type(of: delegate), "sceneWillResignActive:") + } + NotificationCenter.default.addObserver(forName: UIApplication.didFinishLaunchingNotification, + object: nil, queue: .main) { _ in + guard let delegate = UIApplication.shared.delegate else { return } + Self.neuter(type(of: delegate), "applicationDidEnterBackground:") + Self.neuter(type(of: delegate), "applicationWillResignActive:") + } + } + + private static func installPostFilter(_ selector: Selector) { + guard let method = class_getInstanceMethod(NotificationCenter.self, selector) else { return } + let originalImp = method_getImplementation(method) + if selector == NSSelectorFromString("postNotification:") { + typealias PostFn = @convention(c) (NotificationCenter, Selector, NSNotification) -> Void + let original = unsafeBitCast(originalImp, to: PostFn.self) + let block: @convention(block) (NotificationCenter, NSNotification) -> Void = { center, notif in + if suppressionActive && center === NotificationCenter.default + && suppressedNotifications.contains(notif.name.rawValue) { + log.debug("suppressed post: \(notif.name.rawValue, privacy: .public)") + return + } + original(center, selector, notif) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } else { + typealias PostNameFn = @convention(c) + (NotificationCenter, Selector, NSString, AnyObject?, NSDictionary?) -> Void + let original = unsafeBitCast(originalImp, to: PostNameFn.self) + let block: @convention(block) + (NotificationCenter, NSString, AnyObject?, NSDictionary?) -> Void = { center, name, obj, info in + if suppressionActive && center === NotificationCenter.default + && suppressedNotifications.contains(name as String) { + log.debug("suppressed postName: \(name, privacy: .public)") + return + } + original(center, selector, name, obj, info) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } + } + + private static func neuter(_ cls: AnyClass, _ selectorName: String) { + let selector = NSSelectorFromString(selectorName) + guard let method = class_getInstanceMethod(cls, selector) else { + log.error("neuter: \(String(describing: cls), privacy: .public) has no \(selectorName, privacy: .public)") + return + } + typealias LifecycleFn = @convention(c) (AnyObject, Selector, AnyObject?) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: LifecycleFn.self) + let block: @convention(block) (AnyObject, AnyObject?) -> Void = { target, arg in + if suppressionActive { + log.debug("neutered call: \(selectorName, privacy: .public)") + return + } + original(target, selector, arg) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + log.notice("neutered: \(String(describing: cls), privacy: .public).\(selectorName, privacy: .public)") + } + + // Stop hiding the background transition and release the audio exemption + // so the app can shut down cleanly. Without this, the game never receives + // its save-and-quit lifecycle events and the process lingers half-dead. + func prepareForTermination() { + guard !terminating else { return } + terminating = true + Self.suppressionActive = false + if engine.isRunning { + player.stop() + engine.stop() + } + try? AVAudioSession.sharedInstance().setActive(false) + Self.log.notice("terminating: suppression disabled, silent audio stopped") + } + + func start() { + suppressBackgroundLifecycle() + + // AKInterface posts this from its NSApplication.terminate hook, which + // covers Cmd+Q, the menu item and quitting from the Dock. Observe with + // queue nil so it runs synchronously before termination proceeds. + NotificationCenter.default.addObserver(forName: Self.willTerminateNotification, + object: nil, queue: nil) { [weak self] _ in + self?.prepareForTermination() + } + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + Self.log.error("audio session setup failed: \(error, privacy: .public)") + } + + guard let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 44100) else { + Self.log.error("could not create silence buffer") + return + } + buffer.frameLength = buffer.frameCapacity + if let channels = buffer.floatChannelData { + for channel in 0.. Date: Sat, 20 Jun 2026 14:46:33 +0200 Subject: [PATCH 2/2] Move BackgroundKeepAlive class to its own file --- PlayTools/PlayCover.swift | 190 --------------------- PlayTools/Utils/BackgroundKeepAlive.swift | 197 ++++++++++++++++++++++ 2 files changed, 197 insertions(+), 190 deletions(-) create mode 100644 PlayTools/Utils/BackgroundKeepAlive.swift diff --git a/PlayTools/PlayCover.swift b/PlayTools/PlayCover.swift index 50ce71da..33e8fdcb 100644 --- a/PlayTools/PlayCover.swift +++ b/PlayTools/PlayCover.swift @@ -119,193 +119,3 @@ public class PlayCover: NSObject { DispatchQueue.main.asyncAfter(deadline: when, execute: closure) } } - -// Since macOS 15, runningboardd freezes iOS apps as soon as their scene is -// no longer visible. Like on iOS, an app that is actively playing audio is -// exempt, so keep an inaudible player running for the whole app lifetime. -// Requires "UIBackgroundModes: [audio]" in the app's Info.plist. -class BackgroundKeepAlive { - static let shared = BackgroundKeepAlive() - static let log = Logger(subsystem: "io.playcover.PlayTools", category: "BackgroundKeepAlive") - - private let engine = AVAudioEngine() - private let player = AVAudioPlayerNode() - private var silence: AVAudioPCMBuffer? - - // The OS legitimately moves the scene to background when it becomes - // invisible (the signal does not come from NSWindow.occlusionState, so it - // cannot be spoofed there). Games then pause themselves per the iOS - // lifecycle convention. Audio playback already prevents the OS-side - // suspension, so it is sufficient to hide the background transition from - // the app: drop the lifecycle notifications and neuter the corresponding - // app/scene delegate callbacks. - // Name is shared with AKInterface, which posts it from its - // NSApplication.terminate hook before the actual termination starts. - static let willTerminateNotification = - Notification.Name("io.playcover.PlayTools.applicationWillTerminate") - - // Cleared once termination starts: the shutdown sequence replays exactly - // the suppressed lifecycle events (resign active, enter background) so the - // game can save its state — from that point on they must reach the app. - private static var suppressionActive = true - private var terminating = false - - private static let suppressedNotifications: Set = [ - UIApplication.willResignActiveNotification.rawValue, - UIApplication.didEnterBackgroundNotification.rawValue, - UIScene.willDeactivateNotification.rawValue, - UIScene.didEnterBackgroundNotification.rawValue - ] - - private func suppressBackgroundLifecycle() { - Self.installPostFilter(NSSelectorFromString("postNotificationName:object:userInfo:")) - Self.installPostFilter(NSSelectorFromString("postNotification:")) - - NotificationCenter.default.addObserver(forName: UIScene.willConnectNotification, - object: nil, queue: .main) { notif in - guard let scene = notif.object as? UIScene, let delegate = scene.delegate else { return } - Self.neuter(type(of: delegate), "sceneDidEnterBackground:") - Self.neuter(type(of: delegate), "sceneWillResignActive:") - } - NotificationCenter.default.addObserver(forName: UIApplication.didFinishLaunchingNotification, - object: nil, queue: .main) { _ in - guard let delegate = UIApplication.shared.delegate else { return } - Self.neuter(type(of: delegate), "applicationDidEnterBackground:") - Self.neuter(type(of: delegate), "applicationWillResignActive:") - } - } - - private static func installPostFilter(_ selector: Selector) { - guard let method = class_getInstanceMethod(NotificationCenter.self, selector) else { return } - let originalImp = method_getImplementation(method) - if selector == NSSelectorFromString("postNotification:") { - typealias PostFn = @convention(c) (NotificationCenter, Selector, NSNotification) -> Void - let original = unsafeBitCast(originalImp, to: PostFn.self) - let block: @convention(block) (NotificationCenter, NSNotification) -> Void = { center, notif in - if suppressionActive && center === NotificationCenter.default - && suppressedNotifications.contains(notif.name.rawValue) { - log.debug("suppressed post: \(notif.name.rawValue, privacy: .public)") - return - } - original(center, selector, notif) - } - method_setImplementation(method, imp_implementationWithBlock(block)) - } else { - typealias PostNameFn = @convention(c) - (NotificationCenter, Selector, NSString, AnyObject?, NSDictionary?) -> Void - let original = unsafeBitCast(originalImp, to: PostNameFn.self) - let block: @convention(block) - (NotificationCenter, NSString, AnyObject?, NSDictionary?) -> Void = { center, name, obj, info in - if suppressionActive && center === NotificationCenter.default - && suppressedNotifications.contains(name as String) { - log.debug("suppressed postName: \(name, privacy: .public)") - return - } - original(center, selector, name, obj, info) - } - method_setImplementation(method, imp_implementationWithBlock(block)) - } - } - - private static func neuter(_ cls: AnyClass, _ selectorName: String) { - let selector = NSSelectorFromString(selectorName) - guard let method = class_getInstanceMethod(cls, selector) else { - log.error("neuter: \(String(describing: cls), privacy: .public) has no \(selectorName, privacy: .public)") - return - } - typealias LifecycleFn = @convention(c) (AnyObject, Selector, AnyObject?) -> Void - let original = unsafeBitCast(method_getImplementation(method), to: LifecycleFn.self) - let block: @convention(block) (AnyObject, AnyObject?) -> Void = { target, arg in - if suppressionActive { - log.debug("neutered call: \(selectorName, privacy: .public)") - return - } - original(target, selector, arg) - } - method_setImplementation(method, imp_implementationWithBlock(block)) - log.notice("neutered: \(String(describing: cls), privacy: .public).\(selectorName, privacy: .public)") - } - - // Stop hiding the background transition and release the audio exemption - // so the app can shut down cleanly. Without this, the game never receives - // its save-and-quit lifecycle events and the process lingers half-dead. - func prepareForTermination() { - guard !terminating else { return } - terminating = true - Self.suppressionActive = false - if engine.isRunning { - player.stop() - engine.stop() - } - try? AVAudioSession.sharedInstance().setActive(false) - Self.log.notice("terminating: suppression disabled, silent audio stopped") - } - - func start() { - suppressBackgroundLifecycle() - - // AKInterface posts this from its NSApplication.terminate hook, which - // covers Cmd+Q, the menu item and quitting from the Dock. Observe with - // queue nil so it runs synchronously before termination proceeds. - NotificationCenter.default.addObserver(forName: Self.willTerminateNotification, - object: nil, queue: nil) { [weak self] _ in - self?.prepareForTermination() - } - - let session = AVAudioSession.sharedInstance() - do { - try session.setCategory(.playback, options: [.mixWithOthers]) - try session.setActive(true) - } catch { - Self.log.error("audio session setup failed: \(error, privacy: .public)") - } - - guard let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2), - let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 44100) else { - Self.log.error("could not create silence buffer") - return - } - buffer.frameLength = buffer.frameCapacity - if let channels = buffer.floatChannelData { - for channel in 0.. = [ + UIApplication.willResignActiveNotification.rawValue, + UIApplication.didEnterBackgroundNotification.rawValue, + UIScene.willDeactivateNotification.rawValue, + UIScene.didEnterBackgroundNotification.rawValue + ] + + private func suppressBackgroundLifecycle() { + Self.installPostFilter(NSSelectorFromString("postNotificationName:object:userInfo:")) + Self.installPostFilter(NSSelectorFromString("postNotification:")) + + NotificationCenter.default.addObserver(forName: UIScene.willConnectNotification, + object: nil, queue: .main) { notif in + guard let scene = notif.object as? UIScene, let delegate = scene.delegate else { return } + Self.neuter(type(of: delegate), "sceneDidEnterBackground:") + Self.neuter(type(of: delegate), "sceneWillResignActive:") + } + NotificationCenter.default.addObserver(forName: UIApplication.didFinishLaunchingNotification, + object: nil, queue: .main) { _ in + guard let delegate = UIApplication.shared.delegate else { return } + Self.neuter(type(of: delegate), "applicationDidEnterBackground:") + Self.neuter(type(of: delegate), "applicationWillResignActive:") + } + } + + private static func installPostFilter(_ selector: Selector) { + guard let method = class_getInstanceMethod(NotificationCenter.self, selector) else { return } + let originalImp = method_getImplementation(method) + if selector == NSSelectorFromString("postNotification:") { + typealias PostFn = @convention(c) (NotificationCenter, Selector, NSNotification) -> Void + let original = unsafeBitCast(originalImp, to: PostFn.self) + let block: @convention(block) (NotificationCenter, NSNotification) -> Void = { center, notif in + if suppressionActive && center === NotificationCenter.default + && suppressedNotifications.contains(notif.name.rawValue) { + log.debug("suppressed post: \(notif.name.rawValue, privacy: .public)") + return + } + original(center, selector, notif) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } else { + typealias PostNameFn = @convention(c) + (NotificationCenter, Selector, NSString, AnyObject?, NSDictionary?) -> Void + let original = unsafeBitCast(originalImp, to: PostNameFn.self) + let block: @convention(block) + (NotificationCenter, NSString, AnyObject?, NSDictionary?) -> Void = { center, name, obj, info in + if suppressionActive && center === NotificationCenter.default + && suppressedNotifications.contains(name as String) { + log.debug("suppressed postName: \(name, privacy: .public)") + return + } + original(center, selector, name, obj, info) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + } + } + + private static func neuter(_ cls: AnyClass, _ selectorName: String) { + let selector = NSSelectorFromString(selectorName) + guard let method = class_getInstanceMethod(cls, selector) else { + log.error("neuter: \(String(describing: cls), privacy: .public) has no \(selectorName, privacy: .public)") + return + } + typealias LifecycleFn = @convention(c) (AnyObject, Selector, AnyObject?) -> Void + let original = unsafeBitCast(method_getImplementation(method), to: LifecycleFn.self) + let block: @convention(block) (AnyObject, AnyObject?) -> Void = { target, arg in + if suppressionActive { + log.debug("neutered call: \(selectorName, privacy: .public)") + return + } + original(target, selector, arg) + } + method_setImplementation(method, imp_implementationWithBlock(block)) + log.notice("neutered: \(String(describing: cls), privacy: .public).\(selectorName, privacy: .public)") + } + + // Stop hiding the background transition and release the audio exemption + // so the app can shut down cleanly. Without this, the game never receives + // its save-and-quit lifecycle events and the process lingers half-dead. + func prepareForTermination() { + guard !terminating else { return } + terminating = true + Self.suppressionActive = false + if engine.isRunning { + player.stop() + engine.stop() + } + try? AVAudioSession.sharedInstance().setActive(false) + Self.log.notice("terminating: suppression disabled, silent audio stopped") + } + + func start() { + suppressBackgroundLifecycle() + + // AKInterface posts this from its NSApplication.terminate hook, which + // covers Cmd+Q, the menu item and quitting from the Dock. Observe with + // queue nil so it runs synchronously before termination proceeds. + NotificationCenter.default.addObserver(forName: Self.willTerminateNotification, + object: nil, queue: nil) { [weak self] _ in + self?.prepareForTermination() + } + + let session = AVAudioSession.sharedInstance() + do { + try session.setCategory(.playback, options: [.mixWithOthers]) + try session.setActive(true) + } catch { + Self.log.error("audio session setup failed: \(error, privacy: .public)") + } + + guard let format = AVAudioFormat(standardFormatWithSampleRate: 44100, channels: 2), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: 44100) else { + Self.log.error("could not create silence buffer") + return + } + buffer.frameLength = buffer.frameCapacity + if let channels = buffer.floatChannelData { + for channel in 0..