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
114 changes: 66 additions & 48 deletions AKPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -98,6 +66,8 @@ class AKPlugin: NSObject, Plugin {

var cmdPressed: Bool = false
var cursorHideLevel = 0
fileprivate var modifierFlag: UInt = 0

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for the fileprivate modifier since that is the default for any variable, same for the ones in extension AKPlugin.


func hideCursor() {
NSCursor.hide()
cursorHideLevel += 1
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
}
Expand Down
12 changes: 12 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,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("/")
Expand Down Expand Up @@ -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)
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