diff --git a/AKPlugin.swift b/AKPlugin.swift index b0ebfece..944ad8e8 100644 --- a/AKPlugin.swift +++ b/AKPlugin.swift @@ -21,28 +21,12 @@ 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] window.isMovable = true window.isMovableByWindowBackground = true - - if self.hideTitleBarSetting == true { - window.styleMask.insert([.fullSizeContentView]) - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.toolbar = nil - window.title = "" - } - - if self.floatingWindowSetting == true { - window.level = .floating - } - - if let aspectRatio = self.aspectRatioSetting { - window.contentAspectRatio = aspectRatio - } - + applyWindowSettings(to: window) NSWindow.allowsAutomaticWindowTabbing = true } @@ -52,23 +36,7 @@ class AKPlugin: NSObject, Plugin { object: nil, queue: .main) { notif in guard let win = notif.object as? NSWindow else { return } - win.styleMask.insert([.resizable]) - - if self.hideTitleBarSetting == true { - win.styleMask.insert([.fullSizeContentView]) - win.titlebarAppearsTransparent = true - win.titleVisibility = .hidden - win.toolbar = nil - win.title = "" - } - - if self.floatingWindowSetting == true { - win.level = .floating - } - - if let aspectRatio = self.aspectRatioSetting { - win.contentAspectRatio = aspectRatio - } + self.applyWindowSettings(to: win) } } @@ -98,6 +66,8 @@ class AKPlugin: NSObject, Plugin { var cmdPressed: Bool = false var cursorHideLevel = 0 + fileprivate var modifierFlag: UInt = 0 + func hideCursor() { NSCursor.hide() cursorHideLevel += 1 @@ -128,8 +98,61 @@ class AKPlugin: NSObject, Plugin { NSApplication.shared.terminate(self) } - private var modifierFlag: UInt = 0 + func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: value) + } + func setMenuBarVisible(_ visible: Bool) { + 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)) + } +} + +// MARK: - Window appearance + +extension AKPlugin { + fileprivate func applyWindowSettings(to window: NSWindow) { + window.styleMask.insert([.resizable]) + + if hideTitleBarSetting { + window.styleMask.insert([.fullSizeContentView]) + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.toolbar = nil + window.title = "" + } + + if floatingWindowSetting { + window.level = .floating + } + + if let aspectRatio = aspectRatioSetting { + window.contentAspectRatio = aspectRatio + } + } +} + +// MARK: - Input events + +extension AKPlugin { // swiftlint:disable:next function_body_length func setupKeyboard(keyboard: @escaping (UInt16, Bool, Bool, Bool) -> Bool, swapMode: @escaping () -> Bool) { @@ -209,7 +232,7 @@ class AKPlugin: NSObject, Plugin { // Helper to detect whether the event is inside any of the window "traffic-light" buttons func isInTrafficLightArea(_ event: NSEvent) -> Bool { - if self.hideTitleBarSetting == false { + if !self.hideTitleBarSetting { return false } guard let win = event.window else { return false } @@ -278,19 +301,14 @@ class AKPlugin: NSObject, Plugin { return event }) } +} - func urlForApplicationWithBundleIdentifier(_ value: String) -> URL? { - NSWorkspace.shared.urlForApplication(withBundleIdentifier: value) - } - - func setMenuBarVisible(_ visible: Bool) { - NSMenu.setMenuBarVisible(visible) - } +// MARK: - App settings - /// 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 } - private var aspectRatioSetting: NSSize? { +extension AKPlugin { + fileprivate var hideTitleBarSetting: Bool { Self.akAppSettingsData?.hideTitleBar ?? false } + fileprivate var floatingWindowSetting: Bool { Self.akAppSettingsData?.floatingWindow ?? false } + fileprivate var aspectRatioSetting: NSSize? { guard Self.akAppSettingsData?.resolution == 6 else { return nil } diff --git a/PlayTools/PlayCover.swift b/PlayTools/PlayCover.swift index 2040dab3..f080a442 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,11 @@ public class PlayCover: NSObject { PlayInput.shared.initialize() DiscordIPC.shared.initialize() + // runningboardd only freezes invisible scenes since macOS 15 (= iOS 18). + if #available(iOS 18.0, *) { + BackgroundKeepAlive.shared.start() + } + if PlaySettings.shared.rootWorkDir { // Change the working directory to / just like iOS FileManager.default.changeCurrentDirectoryPath("/") @@ -55,6 +62,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) diff --git a/PlayTools/Utils/BackgroundKeepAlive.swift b/PlayTools/Utils/BackgroundKeepAlive.swift new file mode 100644 index 00000000..ee921a88 --- /dev/null +++ b/PlayTools/Utils/BackgroundKeepAlive.swift @@ -0,0 +1,197 @@ +// +// BackgroundKeepAlive.swift +// PlayTools +// + +import AVFoundation +import os + +// 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..