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
49 changes: 44 additions & 5 deletions PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
#import "PTFakeMetaTouch.h"
#import <VideoSubscriberAccount/VideoSubscriberAccount.h>
#import <AVFoundation/AVFoundation.h>
#import <AVFAudio/AVAudioApplication.h>
#import <CoreMotion/CoreMotion.h>
#import <GameController/GameController.h>

Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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]) {
Expand Down
6 changes: 6 additions & 0 deletions PlayTools/PlaySettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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[..<markerRange.lowerBound])
}

let userName = NSUserName()
if let homeDirectory = NSHomeDirectoryForUser(userName) {
return homeDirectory
Expand Down
19 changes: 19 additions & 0 deletions Scripts/verify-macos26-microphone.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -euo pipefail

source_file="PlayTools/Controls/PTFakeTouch/NSObject+Swizzle.m"
settings_file="PlayTools/PlaySettings.swift"

test -f "$source_file"
test -f "$settings_file"
rg -q '#import <AVFAudio/AVAudioApplication.h>' "$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"
39 changes: 39 additions & 0 deletions docs/macos26-microphone-permission-design.md
Original file line number Diff line number Diff line change
@@ -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.