diff --git a/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m b/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m index e50200fb..4e321b3f 100644 --- a/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m +++ b/PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m @@ -13,6 +13,7 @@ #import "PTFakeMetaTouch.h" #import #import +#import #import #import @@ -176,11 +177,37 @@ - (NSString *)hook_stringByReplacingOccurrencesOfRegularExpressionPattern:(NSStr } - (void)hook_requestRecordPermission:(void (^)(BOOL))response { - BOOL granted = [[AVAudioSession sharedInstance] recordPermission] == AVAudioSessionRecordPermissionGranted; - if (granted) { - response(granted); + if (@available(iOS 17.0, macOS 14.0, *)) { + AVAudioApplicationRecordPermission permission = [AVAudioApplication sharedInstance].recordPermission; + if (permission == AVAudioApplicationRecordPermissionGranted) { + response(YES); + } else if (permission == AVAudioApplicationRecordPermissionDenied) { + response(NO); + } else { + [AVAudioApplication requestRecordPermissionWithCompletionHandler:response]; + } + } else { + BOOL granted = [[AVAudioSession sharedInstance] recordPermission] == AVAudioSessionRecordPermissionGranted; + if (granted) { + response(YES); + } else { + [self hook_requestRecordPermission:response]; + } + } +} + ++ (void)hook_requestRecordPermissionWithCompletionHandler:(void (^)(BOOL))response { + if (@available(iOS 17.0, macOS 14.0, *)) { + AVAudioApplicationRecordPermission permission = [AVAudioApplication sharedInstance].recordPermission; + if (permission == AVAudioApplicationRecordPermissionGranted) { + response(YES); + } else if (permission == AVAudioApplicationRecordPermissionDenied) { + response(NO); + } else { + [self hook_requestRecordPermissionWithCompletionHandler:response]; + } } else { - [self hook_requestRecordPermission:response]; + response(NO); } } @@ -351,7 +378,19 @@ + (void)load { } if ([[PlaySettings shared] checkMicPermissionSync]) { - [objc_getClass("AVAudioSession") swizzleInstanceMethod:@selector(requestRecordPermission:) withMethod:@selector(hook_requestRecordPermission:)]; + Class audioSession = objc_getClass("AVAudioSession"); + SEL legacyRequestSelector = @selector(requestRecordPermission:); + if (audioSession && class_getInstanceMethod(audioSession, legacyRequestSelector)) { + [audioSession swizzleInstanceMethod:legacyRequestSelector + withMethod:@selector(hook_requestRecordPermission:)]; + } + + Class audioApplication = objc_getClass("AVAudioApplication"); + SEL applicationRequestSelector = @selector(requestRecordPermissionWithCompletionHandler:); + if (audioApplication && class_getClassMethod(audioApplication, applicationRequestSelector)) { + [audioApplication swizzleClassMethod:applicationRequestSelector + withMethod:@selector(hook_requestRecordPermissionWithCompletionHandler:)]; + } } if ([[PlaySettings shared] limitMotionUpdateFrequency]) { diff --git a/PlayTools/PlaySettings.swift b/PlayTools/PlaySettings.swift index cf14058d..1b28e3d9 100644 --- a/PlayTools/PlaySettings.swift +++ b/PlayTools/PlaySettings.swift @@ -4,6 +4,12 @@ import UIKit let settings = PlaySettings.shared func playCoverUserHomeDirectoryPath() -> String { + let bundlePath = Bundle.main.bundlePath + let containerMarker = "/Library/Containers/io.playcover.PlayCover/" + if let markerRange = bundlePath.range(of: containerMarker) { + return String(bundlePath[..' "$source_file" +rg -q 'hook_requestRecordPermissionWithCompletionHandler:' "$source_file" +rg -q 'requestRecordPermissionWithCompletionHandler:' "$source_file" +rg -q 'AVAudioApplicationRecordPermissionGranted' "$source_file" +rg -q 'AVAudioApplicationRecordPermissionDenied' "$source_file" +rg -q 'AVAudioSessionRecordPermissionGranted' "$source_file" +rg -q 'swizzleClassMethod:applicationRequestSelector' "$source_file" +rg -q 'Bundle\.main\.bundlePath' "$settings_file" +rg -q '/Library/Containers/io\.playcover\.PlayCover/' "$settings_file" + +echo "macOS 26 microphone compatibility checks passed" diff --git a/docs/macos26-microphone-permission-design.md b/docs/macos26-microphone-permission-design.md new file mode 100644 index 00000000..997cec3c --- /dev/null +++ b/docs/macos26-microphone-permission-design.md @@ -0,0 +1,39 @@ +# macOS 26 Microphone Permission Compatibility + +## Problem + +PlayTools currently synchronizes the legacy `AVAudioSession` microphone +permission callback. On macOS 26, the supported application-level permission +API is `AVAudioApplication`, so games that reach the newer API—or games whose +legacy request is bridged through the newer API—can report a denied permission +without creating a TCC request for the translated app. + +## Design + +Keep the existing opt-in `checkMicPermissionSync` behavior and extend it at the +PlayTools boundary: + +1. Add a class-method hook for + `AVAudioApplication.requestRecordPermissionWithCompletionHandler:`. +2. When the new API is available, make the legacy + `AVAudioSession.requestRecordPermission:` hook use the new API as its + authorization source. A granted or denied state is returned synchronously; + an undetermined state is forwarded to the real + `AVAudioApplication.requestRecordPermissionWithCompletionHandler:` request + so macOS can update TCC and invoke the game callback. +3. Keep the existing `AVAudioSession` fallback for older runtimes where + `AVAudioApplication` is unavailable. +4. Install both hooks only when `checkMicPermissionSync` is enabled, matching + the existing per-app opt-in behavior. + +The hook does not fabricate permission. It delegates the decision to Apple's +permission API and only normalizes an already-determined result to synchronous +callback behavior required by affected games. + +## Verification + +- A source-level regression script checks that the new API header, hook, + fallback, and registration are all present. +- The PlayTools Xcode project is built with the current macOS 26 SDK. +- The resulting framework is inspected for the new selectors and its + signature is verified after Catalyst conversion.