Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions AKPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
var resizableAspectRatioHeight: Int?
}

class AKPlugin: NSObject, Plugin {

Check failure on line 21 in AKPlugin.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Type Body Length Violation: Class body should span 250 lines or less excluding comments and whitespace: currently spans 264 lines (type_body_length)
required override init() {
super.init()
Self.hookTermination()
if let window = NSApplication.shared.windows.first {
window.styleMask.insert([.resizable])
window.collectionBehavior = [.fullScreenPrimary, .managed, .participatesInCycle]
Expand Down Expand Up @@ -287,6 +288,25 @@
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 }
Expand Down
13 changes: 13 additions & 0 deletions PlayTools/PlayCover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

import Foundation
import UIKit
import AVFoundation
import os

public class PlayCover: NSObject {

Expand All @@ -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("/")
Expand Down Expand Up @@ -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)
Expand Down
197 changes: 197 additions & 0 deletions PlayTools/Utils/BackgroundKeepAlive.swift
Original file line number Diff line number Diff line change
@@ -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<String> = [
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..<Int(format.channelCount) {
channels[channel].update(repeating: 0, count: Int(buffer.frameLength))
}
}
silence = buffer

engine.attach(player)
engine.connect(player, to: engine.mainMixerNode, format: format)
engine.mainMixerNode.outputVolume = 0

// The engine stops on output device changes and session interruptions;
// restart it or the suspension exemption is silently lost.
NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange,
object: engine,
queue: .main) { [weak self] _ in
self?.ensureRunning()
}
NotificationCenter.default.addObserver(forName: AVAudioSession.interruptionNotification,
object: nil,
queue: .main) { [weak self] _ in
self?.ensureRunning()
}

ensureRunning()
}

private func ensureRunning() {
guard !terminating, let silence = silence else { return }
do {
if !engine.isRunning {
try engine.start()
}
if !player.isPlaying {
player.scheduleBuffer(silence, at: nil, options: .loops)
player.play()
}
Self.log.notice("silent audio running")
} catch {
Self.log.error("engine start failed: \(error, privacy: .public)")
}
}
}
Loading