From 61ea2710bdb57859957302a7447fd2474d0af511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 12:25:42 +0200 Subject: [PATCH 1/4] iOS: Lift stuck touches when the scene resigns active Pulling down Control Center starts a touch on our view that UIKit doesn't always end or cancel. The tracker kept that slot down forever, and touch input stopped working until the app was restarted. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/Controls.h | 2 ++ ios/Controls.mm | 12 ++++++++++++ ios/ViewController.mm | 4 ++++ 3 files changed, 18 insertions(+) diff --git a/ios/Controls.h b/ios/Controls.h index cb865ae1c8..181d99e557 100644 --- a/ios/Controls.h +++ b/ios/Controls.h @@ -26,6 +26,8 @@ public: void Moved(NSSet *touches, UIView *view); void Ended(NSSet *touches, UIView *view); void Cancelled(NSSet *touches, UIView *view); + // Lifts every touch still down. UIKit doesn't always end the touches that pull down Control Center. + void CancelAll(); private: void SendTouchEvent(CGPoint point, UIView *view, int code, int pointerId); int ToTouchID(UITouch *uiTouch, bool allowAllocate); diff --git a/ios/Controls.mm b/ios/Controls.mm index c7ad099ffa..11864756be 100644 --- a/ios/Controls.mm +++ b/ios/Controls.mm @@ -381,6 +381,18 @@ void TouchTracker::Cancelled(NSSet *touches, UIView *view) { } } +void TouchTracker::CancelAll() { + for (int touchId = 0; touchId < (int)ARRAY_SIZE(touches_); ++touchId) { + if (touches_[touchId]) { + TouchInput input{}; + input.flags = TouchInputFlags::UP | TouchInputFlags::CANCEL; + input.id = touchId; + NativeTouch(input); + touches_[touchId] = nullptr; + } + } +} + #if !PPSSPP_PLATFORM(TVOS) void ProcessAccelerometerData(CMAccelerometerData *accData) { CMAcceleration acc = accData.acceleration; diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 9cb97e139e..8befa1043e 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -337,6 +337,10 @@ static int GetPickerRequestId(id picker) { - (void)willResignActive { [self requestExitVulkanRenderLoop]; +#if !PPSSPP_PLATFORM(TVOS) + g_touchTracker.CancelAll(); +#endif + #if !PPSSPP_PLATFORM(TVOS) // Stop accelerometer updates if (self.motionManager.accelerometerActive) { From 2de8008ec8f182e056f2ed7c278177428f46b558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 12:25:42 +0200 Subject: [PATCH 2/4] iOS: Don't abort when exiting with the Exit button EXIT_APP is handled on the render thread, and calling exit() there ran the destructor of the still joinable render thread std::thread, which calls std::terminate. Shut the view controller down on the main thread first, which joins the render thread, then exit. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/main.mm | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/ios/main.mm b/ios/main.mm index f05106bb91..c748f04d09 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -506,12 +506,12 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string } case SystemRequestType::EXIT_APP: // NOTE: on iOS, this is considered a crash and not a valid way to exit. - exit(0); - // The below seems right, but causes hangs. See #12140. - // dispatch_async(dispatch_get_main_queue(), ^{ - // [sharedViewController shutdown]; - // exit(0); - // }); + // We're on the render thread. Calling exit() here would destroy its still joinable std::thread, + // which aborts, so let the main thread join it first. + dispatch_async(dispatch_get_main_queue(), ^{ + [sharedViewController shutdown]; + exit(0); + }); break; case SystemRequestType::BROWSE_FOR_FILE: { From f993f3b9b2821d1d9c12d85bf1da2f97d31ad26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 12:25:42 +0200 Subject: [PATCH 3/4] iOS: Allow AirPlay while recording, log audio route changes The playback categories we use route to AirPlay on their own (checked with screen mirroring to an Apple TV, with every combination of the silent mode and mix-with-others settings). PlayAndRecord, used while a game records from the microphone, needs AllowAirPlay for that. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/AppDelegate.mm | 9 +++++++++ ios/iOSCoreAudio.h | 3 +++ ios/iOSCoreAudio.mm | 12 +++++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ios/AppDelegate.mm b/ios/AppDelegate.mm index bd9d44ac96..be35453073 100644 --- a/ios/AppDelegate.mm +++ b/ios/AppDelegate.mm @@ -82,6 +82,14 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas iOSCoreAudioInit(); } +// for AVAudioSessionRouteChangeNotification +-(void) handleAudioRouteChange:(NSNotification *)notification { + NSNumber *reason = notification.userInfo[AVAudioSessionRouteChangeReasonKey]; + char buf[32]; + snprintf(buf, sizeof(buf), "change, reason %d", reason.intValue); + iOSCoreAudioLogRoute(buf); +} + -(BOOL) application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.launchOptions = launchOptions; @@ -91,6 +99,7 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioSessionInterruption:) name:AVAudioSessionInterruptionNotification object:[AVAudioSession sharedInstance]]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleMediaServicesWereReset:) name:AVAudioSessionMediaServicesWereResetNotification object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleAudioRouteChange:) name:AVAudioSessionRouteChangeNotification object:nil]; return YES; } diff --git a/ios/iOSCoreAudio.h b/ios/iOSCoreAudio.h index 3a0fb5427f..ac6e4e69ec 100644 --- a/ios/iOSCoreAudio.h +++ b/ios/iOSCoreAudio.h @@ -25,6 +25,9 @@ void iOSCoreAudioShutdown(); // Applies the audio settings (mix with others, respect silent mode) to the session. void iOSCoreAudioUpdateSession(); +// Logs where the session's output currently goes (speaker, headphones, AirPlay...). +void iOSCoreAudioLogRoute(const char *reason); + // Microphone input, for sceUsbMic. These are called on the CPU thread. Input is buffered, and // iOSCoreAudioPollRecording hands it over to Microphone::addAudioData. void iOSCoreAudioStartRecording(int sampleRate); diff --git a/ios/iOSCoreAudio.mm b/ios/iOSCoreAudio.mm index 6522524b39..9d1c2a63c8 100644 --- a/ios/iOSCoreAudio.mm +++ b/ios/iOSCoreAudio.mm @@ -71,7 +71,8 @@ void iOSCoreAudioUpdateSession() { if (g_micEnabled) { // Recording needs PlayAndRecord, which ignores the silent switch. A2DP rather than the // hands-free profile keeps Bluetooth headphones at full quality, with the built-in mic as input. - AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetoothA2DP; + // Unlike the playback categories, PlayAndRecord needs to be told to allow AirPlay. + AVAudioSessionCategoryOptions options = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionAllowBluetoothA2DP | AVAudioSessionCategoryOptionAllowAirPlay; if (g_Config.bAudioMixWithOthers) { options |= AVAudioSessionCategoryOptionMixWithOthers; } @@ -97,6 +98,13 @@ void iOSCoreAudioUpdateSession() { } } +void iOSCoreAudioLogRoute(const char *reason) { + AVAudioSession *session = [AVAudioSession sharedInstance]; + for (AVAudioSessionPortDescription *port in session.currentRoute.outputs) { + NSLog(@"Audio route (%s): %@ '%@' (category %@, options %d)", reason, port.portType, port.portName, session.category, (int)session.categoryOptions); + } +} + void NativeMix(short *audio, int numSamples, int sampleRateHz, void *userdata); OSStatus iOSCoreAudioCallback(void *inRefCon, @@ -204,6 +212,8 @@ void iOSCoreAudioInit() { } } + iOSCoreAudioLogRoute("init"); + if (audioInstance) { // Already running return; From b8c9e82fe68f080c2b9b1f55978e59281a09fdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Thu, 22 Oct 2026 12:31:43 +0200 Subject: [PATCH 4/4] iOS: Leave PlayAndRecord when a game stops recording The session category was switched before the audio unit's input was turned off, and iOS refuses ('!pri') to leave PlayAndRecord while the input runs. So after a game used the microphone, the session stayed in PlayAndRecord, ignoring the silent mode setting. Switch it while the unit is stopped instead. Also log the two setCategory failures separately, and the route's input. Co-Authored-By: Claude Opus 5.5 (1M context) --- ios/iOSCoreAudio.mm | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/ios/iOSCoreAudio.mm b/ios/iOSCoreAudio.mm index 9d1c2a63c8..3293af693a 100644 --- a/ios/iOSCoreAudio.mm +++ b/ios/iOSCoreAudio.mm @@ -65,7 +65,10 @@ void iOSCoreAudioUpdateSession() { #else // Hacky hack to force iOS to re-evaluate. // Switching from CatogoryPlayback to CategoryPlayback with an option otherwise does nothing. - [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAudioProcessing error:&error]; + NSError *hackError = nil; + if (![[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryAudioProcessing error:&hackError]) { + NSLog(@"Switching to AudioProcessing failed: %@", hackError); + } // Here, we apply the settings. if (g_micEnabled) { @@ -94,7 +97,7 @@ void iOSCoreAudioUpdateSession() { #endif if (error) { - NSLog(@"%@", error); + NSLog(@"Setting the audio session category failed: %@", error); } } @@ -103,6 +106,9 @@ void iOSCoreAudioLogRoute(const char *reason) { for (AVAudioSessionPortDescription *port in session.currentRoute.outputs) { NSLog(@"Audio route (%s): %@ '%@' (category %@, options %d)", reason, port.portType, port.portName, session.category, (int)session.categoryOptions); } + for (AVAudioSessionPortDescription *port in session.currentRoute.inputs) { + NSLog(@"Audio input (%s): %@ '%@'", reason, port.portType, port.portName); + } } void NativeMix(short *audio, int numSamples, int sampleRateHz, void *userdata); @@ -317,14 +323,15 @@ void iOSCoreAudioShutdown() // Brings the unit in line with g_micEnabled. Main thread. static void UpdateMicrophone() { - iOSCoreAudioUpdateSession(); if (!audioInstance) { + // Sets up the session too. iOSCoreAudioInit(); return; } if (!g_Config.bEnableSound && !g_micEnabled) { // The unit was only running for the microphone. iOSCoreAudioShutdown(); + iOSCoreAudioUpdateSession(); return; } if (g_micEnabled == g_micInputOn && (!g_micEnabled || g_micSampleRate == g_micInputRate)) { @@ -333,6 +340,8 @@ static void UpdateMicrophone() { AudioOutputUnitStop(audioInstance); AudioUnitUninitialize(audioInstance); + // Only with the unit stopped: iOS refuses (with '!pri') to leave PlayAndRecord while the input runs. + iOSCoreAudioUpdateSession(); ConfigureInput(audioInstance, g_micEnabled); OSStatus err = AudioUnitInitialize(audioInstance); if (err == noErr) {