From 3a8a1799eeb4c9865f3bdd6b9a71acf25e2fa008 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20Rydg=C3=A5rd?= Date: Tue, 13 Oct 2026 01:06:09 +0200 Subject: [PATCH] Add a tvOS build of the app Shares the iOS frontend, with camera, location, motion, photo/document pickers, share sheet, clipboard, microphone, battery and rotation compiled out. The view controller is a GCEventViewController, so controllers only reach us through GCController, and the Siri Remote is read as a micro gamepad and sent as arrow/enter/escape/back keys. tvOS has no persistent storage besides NSUserDefaults, so the memory stick lives in Caches for now. Configure with -DIOS_PLATFORM=TVOS. The MoltenVK submodule gets the tvOS slice from the same stock v1.4.2 release. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 52 ++++++++++--- Core/Util/DarwinFileSystemServices.mm | 26 +++++-- cmake/Toolchains/ios.cmake | 3 + ios/AppDelegate.mm | 6 +- ios/Controls.h | 11 +++ ios/Controls.mm | 54 +++++++++++++ ios/MoltenVK | 2 +- ios/SceneDelegate.mm | 5 ++ ios/ViewController.h | 16 +++- ios/ViewController.mm | 107 +++++++++++++++++++++++--- ios/iOSCoreAudio.mm | 14 ++++ ios/main.mm | 77 +++++++++++++++++- ios/tvOS-Info.plist | 71 +++++++++++++++++ 13 files changed, 412 insertions(+), 32 deletions(-) create mode 100644 ios/tvOS-Info.plist diff --git a/CMakeLists.txt b/CMakeLists.txt index ad2b76857f..5254959dd6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -953,18 +953,26 @@ elseif(IOS AND NOT LIBRETRO) ios/iOSCoreAudio.h ios/IAPManager.mm ios/IAPManager.h - ios/CameraHelper.mm - ios/CameraHelper.h - ios/LocationHelper.mm - ios/LocationHelper.h - Common/Battery/AppleBatteryClient.m ) + # tvOS has no camera, location, motion, battery or photo picker. + if(NOT TVOS) + list(APPEND targetExtra + ios/CameraHelper.mm + ios/CameraHelper.h + ios/LocationHelper.mm + ios/LocationHelper.h + Common/Battery/AppleBatteryClient.m + ) + endif() - list(APPEND targetExtraLibs "-framework Foundation -framework MediaPlayer -framework AudioToolbox -framework CoreGraphics -framework CoreMotion -framework QuartzCore -framework UIKit -framework OpenAL -framework AVFoundation -framework CoreLocation -framework CoreText -framework CoreVideo -framework CoreMedia -framework CoreServices -framework Metal -framework IOSurface -framework Photos" ) + list(APPEND targetExtraLibs "-framework Foundation -framework MediaPlayer -framework AudioToolbox -framework CoreGraphics -framework QuartzCore -framework UIKit -framework OpenAL -framework AVFoundation -framework CoreText -framework CoreVideo -framework CoreMedia -framework CoreServices -framework Metal -framework IOSurface" ) + if(NOT TVOS) + list(APPEND targetExtraLibs "-framework CoreMotion -framework CoreLocation -framework Photos") + endif() if(EXISTS "${CMAKE_IOS_SDK_ROOT}/System/Library/Frameworks/GameController.framework") list(APPEND targetExtraLibs "-weak_framework GameController") endif() - if(EXISTS "${CMAKE_IOS_SDK_ROOT}/System/Library/Frameworks/PhotosUI.framework") + if(NOT TVOS AND EXISTS "${CMAKE_IOS_SDK_ROOT}/System/Library/Frameworks/PhotosUI.framework") list(APPEND targetExtraLibs "-weak_framework PhotosUI") endif() list(APPEND targetExtraLibs "-framework UniformTypeIdentifiers") @@ -1496,7 +1504,9 @@ if(TargetBin) set_source_files_properties(${WEB_UPLOAD_FILES} PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/assets/upload") endif() - if(IOS) + if(TVOS) + add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource}) + elseif(IOS) set(AssetCatalog "${CMAKE_SOURCE_DIR}/ios/assets.xcassets") add_executable(${TargetBin} MACOSX_BUNDLE ${NativeAssets} ${BigFontAssets} ${UIImages} ${AssetCatalog} ${SHADER_FILES} ${THEME_FILE} ${DEBUGGER_FILES} ${WEB_UPLOAD_FILES} ${FLASH0_FILES} ${LANG_FILES} ${NativeAppSource} "ios/Launch Screen.storyboard") else() @@ -1556,6 +1566,11 @@ if(IOS AND NOT LIBRETRO) if(IOS_DEBUG) file(INSTALL pspautotests DESTINATION ${CMAKE_CURRENT_BINARY_DIR}/assets) endif() + if(TVOS) + set(MOLTENVK_SLICE tvos-arm64) + else() + set(MOLTENVK_SLICE ios-arm64) + endif() set(RSRC_XIB_FILES "Launch Screen.storyboard" ${CMAKE_CURRENT_SOURCE_DIR}/ios/assets.xcassets) set_source_files_properties(${RSRC_XIB_FILES} @@ -1571,12 +1586,16 @@ if(IOS AND NOT LIBRETRO) # Can't figure out properly using .xcframework from CMake, so just linking directly to the .a file. target_link_libraries(${TargetBin} - "${CMAKE_CURRENT_SOURCE_DIR}/ios/MoltenVK/MoltenVK.xcframework/ios-arm64/libMoltenVK.a" + "${CMAKE_CURRENT_SOURCE_DIR}/ios/MoltenVK/MoltenVK.xcframework/${MOLTENVK_SLICE}/libMoltenVK.a" ) set(MACOSX_DEPLOYMENT_TARGET ${DEPLOYMENT_TARGET}) set(PRODUCT_NAME "PPSSPP") - set(BUNDLE_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/PPSSPP-Info.plist") + if(TVOS) + set(BUNDLE_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/tvOS-Info.plist") + else() + set(BUNDLE_PLIST "${CMAKE_CURRENT_SOURCE_DIR}/ios/PPSSPP-Info.plist") + endif() set(BUNDLE_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/ios/App.entitlements") if(GOLD) if(IOS_APP_STORE) @@ -1638,6 +1657,19 @@ if(IOS AND NOT LIBRETRO) XCODE_ATTRIBUTE_SKIP_INSTALL NO XCODE_ATTRIBUTE_INSTALL_PATH "$(LOCAL_APPS_DIR)" ) + if(TVOS) + # The launch storyboard and the asset catalog's icons are iPhone/iPad only. tvOS needs a + # layered App Icon & Top Shelf brand asset, which we don't have yet. + set_target_properties(${TargetBin} PROPERTIES + RESOURCE "" + XCODE_ATTRIBUTE_ASSETCATALOG_COMPILER_APPICON_NAME "" + XCODE_ATTRIBUTE_IPHONEOS_DEPLOYMENT_TARGET "" + XCODE_ATTRIBUTE_TVOS_DEPLOYMENT_TARGET ${DEPLOYMENT_TARGET} + XCODE_ATTRIBUTE_TARGETED_DEVICE_FAMILY "3" + XCODE_ATTRIBUTE_SDKROOT appletvos + XCODE_ATTRIBUTE_SUPPORTED_PLATFORMS appletvos + ) + endif() if(IOS_APP_STORE) # https://stackoverflow.com/questions/40664125/cmake-and-code-signing-in-xcode-8-for-ios-projects set_target_properties(${TargetBin} PROPERTIES diff --git a/Core/Util/DarwinFileSystemServices.mm b/Core/Util/DarwinFileSystemServices.mm index c1b9f8da07..dfe719f6ff 100644 --- a/Core/Util/DarwinFileSystemServices.mm +++ b/Core/Util/DarwinFileSystemServices.mm @@ -34,10 +34,6 @@ #include #include -@interface DocumentPickerDelegate : NSObject -@property DarwinDirectoryPanelCallback panelCallback; -@end - static std::map activeAccessTokens; void *DarwinFileSystemServices::__pickerDelegate = nullptr; @@ -48,6 +44,14 @@ void DarwinFileSystemServices::ClearDelegate() { __pickerDelegate = NULL; } +static void addBookmark(NSURL *url); + +// tvOS has no document picker. +#if !PPSSPP_PLATFORM(TVOS) +@interface DocumentPickerDelegate : NSObject +@property DarwinDirectoryPanelCallback panelCallback; +@end + @implementation DocumentPickerDelegate -(instancetype)initWithCallback: (DarwinDirectoryPanelCallback)panelCallback { if (self = [super init]) { @@ -56,8 +60,6 @@ void DarwinFileSystemServices::ClearDelegate() { return self; } -static void addBookmark(NSURL *url); - - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { NSURL *url = urls.firstObject; if (!url) { @@ -91,6 +93,7 @@ static void addBookmark(NSURL *url); [sharedViewController hideKeyboard]; } @end +#endif // !PPSSPP_PLATFORM(TVOS) // Internal helper to generate and store a bookmark for a given URL. // Also activates the security scope for the current session. @@ -279,6 +282,9 @@ void DarwinFileSystemServices::presentDirectoryPanel( WARN_LOG(Log::System, "Mac: Received unknown responsde from modal"); panelCallback(false, Path()); } +#elif PPSSPP_PLATFORM(TVOS) + // No document picker. SYSPROP_HAS_FILE_BROWSER/FOLDER_BROWSER are false, so we shouldn't get here. + panelCallback(false, Path()); #elif PPSSPP_PLATFORM(IOS) // The window's root view controller. UIViewController *rootViewController = sharedViewController; @@ -310,7 +316,13 @@ Path DarwinFileSystemServices::appropriateMemoryStickDirectoryToUse() { } Path DarwinFileSystemServices::defaultMemoryStickPath() { -#if PPSSPP_PLATFORM(IOS) +#if PPSSPP_PLATFORM(TVOS) + // tvOS apps get no persistent storage beyond NSUserDefaults. Everything else has to be + // purgeable, so the memory stick lives in Caches, and the system may clear it when space runs low. + NSString *cachesPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) + objectAtIndex:0]; + return Path(cachesPath.UTF8String); +#elif PPSSPP_PLATFORM(IOS) NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; return Path(documentsPath.UTF8String); diff --git a/cmake/Toolchains/ios.cmake b/cmake/Toolchains/ios.cmake index ce3206f65c..72f5fa8dc3 100644 --- a/cmake/Toolchains/ios.cmake +++ b/cmake/Toolchains/ios.cmake @@ -50,6 +50,9 @@ if(NOT DEFINED IOS_PLATFORM) set(IOS_PLATFORM "OS") endif() set(IOS_PLATFORM ${IOS_PLATFORM} CACHE STRING "Type of iOS Platform") +if(IOS_PLATFORM STREQUAL "TVOS") + set(TVOS ON) +endif() # Check the platform selection and setup for developer root if(IOS_PLATFORM STREQUAL "OS") diff --git a/ios/AppDelegate.mm b/ios/AppDelegate.mm index 337956475f..bd9d44ac96 100644 --- a/ios/AppDelegate.mm +++ b/ios/AppDelegate.mm @@ -214,6 +214,7 @@ static std::vector GatherGameLibrary() { }); } +#if !PPSSPP_PLATFORM(TVOS) - (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { switch (g_Config.iScreenRotation) { case ROTATION_LOCKED_HORIZONTAL: @@ -235,6 +236,7 @@ static std::vector GatherGameLibrary() { return UIInterfaceOrientationMaskAll; } } +#endif @end @@ -254,8 +256,10 @@ void copyDeepLinkForPath(std::string_view filePath) { // Format: scheme://host?query NSString *deepLink = [NSString stringWithFormat:@"ppsspp://open?path=%@", encodedPath]; - // 4. Copy to the iOS System Clipboard (UIPasteboard) + // 4. Copy to the iOS System Clipboard (UIPasteboard). tvOS has none. +#if !PPSSPP_PLATFORM(TVOS) [UIPasteboard generalPasteboard].string = deepLink; +#endif // Optional: Log it for debugging NSLog(@"Deep link copied: %@", deepLink); diff --git a/ios/Controls.h b/ios/Controls.h index 49ec243056..1783fd2d2d 100644 --- a/ios/Controls.h +++ b/ios/Controls.h @@ -2,8 +2,12 @@ #include +#include "ppsspp_config.h" + #import +#if !PPSSPP_PLATFORM(TVOS) #import +#endif #include "Common/Input/InputState.h" @@ -13,6 +17,11 @@ bool InitController(GCController *controller); void ShutdownController(GCController *controller); +#if PPSSPP_PLATFORM(TVOS) +// Returns false unless the controller is a Siri Remote (a micro gamepad profile and no extended one). +bool InitSiriRemote(GCController *controller); +#endif + struct TouchTracker { public: void Began(NSSet *touches, UIView *view); @@ -25,7 +34,9 @@ private: UITouch *touches_[10]{}; }; +#if !PPSSPP_PLATFORM(TVOS) void ProcessAccelerometerData(CMAccelerometerData *accData); +#endif InputKeyCode HIDUsageToInputKeyCode(UIKeyboardHIDUsage usage); void KeyboardPressesBegan(NSSet *presses, UIPressesEvent *event); diff --git a/ios/Controls.mm b/ios/Controls.mm index 19a291c318..db966a4196 100644 --- a/ios/Controls.mm +++ b/ios/Controls.mm @@ -200,6 +200,58 @@ void ShutdownController(GCController *controller) { KeyMap::NotifyPadDisconnected(DEVICE_ID_PAD_0); } +#if PPSSPP_PLATFORM(TVOS) + +static void remoteButtonPressed(BOOL pressed, InputKeyCode keyCode) { + // As keyboard keys, which the UI already understands (arrows, enter, escape), and which have + // default in-game mappings. + KeyInput key; + key.deviceId = DEVICE_ID_KEYBOARD; + key.flags = pressed ? KeyInputFlags::DOWN : KeyInputFlags::UP; + key.keyCode = keyCode; + NativeKey(key); +} + +bool InitSiriRemote(GCController *controller) { + GCMicroGamepad *micro = controller.microGamepad; + if (micro == nil || controller.extendedGamepad != nil) { + return false; + } + + // Swipes on the touch surface move relative to where the finger went down, one step per swipe. + micro.reportsAbsoluteDpadValues = NO; + micro.allowsRotation = NO; + + micro.dpad.up.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_DPAD_UP); + }; + micro.dpad.down.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_DPAD_DOWN); + }; + micro.dpad.left.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_DPAD_LEFT); + }; + micro.dpad.right.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_DPAD_RIGHT); + }; + // Clicking the touch surface. + micro.buttonA.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_ENTER); + }; + // Play/pause. Escape opens the pause menu in-game. + micro.buttonX.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_ESCAPE); + }; + // The Menu (or Back) button. + micro.buttonMenu.pressedChangedHandler = ^(GCControllerButtonInput *button, float value, BOOL pressed) { + remoteButtonPressed(pressed, NKCODE_BACK); + }; + INFO_LOG(Log::System, "Siri Remote set up: %s", [controller.vendorName ?: @"Remote" UTF8String]); + return true; +} + +#endif + void TouchTracker::SendTouchEvent(CGPoint point, UIView *view, int code, int pointerId) { // From points to dp. The view covers the whole display. CGSize size = view.bounds.size; @@ -281,6 +333,7 @@ void TouchTracker::Cancelled(NSSet *touches, UIView *view) { } } +#if !PPSSPP_PLATFORM(TVOS) void ProcessAccelerometerData(CMAccelerometerData *accData) { CMAcceleration acc = accData.acceleration; // INFO_LOG(SYSTEM, "%f %f %f", acc.x, acc.y, acc.z); @@ -288,6 +341,7 @@ void ProcessAccelerometerData(CMAccelerometerData *accData) { // Might need to change these for portrait or inverse landscape NativeAccelerometer(-acc.x, -acc.y, -acc.z); } +#endif void SendKeyboardChars(std::string_view str) { UTF8 chars(str); diff --git a/ios/MoltenVK b/ios/MoltenVK index 69cc97ff02..b77380b5c8 160000 --- a/ios/MoltenVK +++ b/ios/MoltenVK @@ -1 +1 @@ -Subproject commit 69cc97ff02929269390dd3aa344c77a702f69403 +Subproject commit b77380b5c84653b96a058bcacb04d305628b1391 diff --git a/ios/SceneDelegate.mm b/ios/SceneDelegate.mm index fc80ed1883..b48468f381 100644 --- a/ios/SceneDelegate.mm +++ b/ios/SceneDelegate.mm @@ -167,7 +167,12 @@ static NSString *ExtractGameInfoScheme(NSURL *url) { break; } +#if PPSSPP_PLATFORM(TVOS) + // No persistent storage on tvOS, see DarwinFileSystemServices::defaultMemoryStickPath(). + NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0]; +#else NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) objectAtIndex:0]; +#endif NSString *bundlePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingString:@"/assets/"]; // Caches the system may clear when space runs low, and the zips ShareFilesAsZip hands to other apps. NSString *cachesPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0]; diff --git a/ios/ViewController.h b/ios/ViewController.h index 5176731f2c..07a7105963 100644 --- a/ios/ViewController.h +++ b/ios/ViewController.h @@ -1,10 +1,22 @@ #pragma once +#include "ppsspp_config.h" + #import -#import #import +#if PPSSPP_PLATFORM(TVOS) + +// The one view controller. Renders with Vulkan (MoltenVK) to a CAMetalLayer. +// A GCEventViewController, so controller input (the Siri Remote included) reaches us only through +// GCController, instead of also driving UIKit's focus engine. +@interface PPSSPPViewController : GCEventViewController + +#else + +#import + #import "CameraHelper.h" #import "LocationHelper.h" @@ -14,6 +26,8 @@ CameraFrameDelegate, LocationHandlerDelegate, UIGestureRecognizerDelegate> +#endif + - (void)hideKeyboard; - (void)showKeyboard; - (void)shareText:(NSString *)text; diff --git a/ios/ViewController.mm b/ios/ViewController.mm index 0f295754d7..be0f11722c 100644 --- a/ios/ViewController.mm +++ b/ios/ViewController.mm @@ -1,8 +1,10 @@ -#import "ios/CameraHelper.h" #import "ios/ViewController.h" #import "ios/Controls.h" #import "ios/IAPManager.h" +#if !PPSSPP_PLATFORM(TVOS) +#import "ios/CameraHelper.h" #import +#endif #import #import #include @@ -135,23 +137,31 @@ static void VulkanRenderLoop(GraphicsContext *graphicsContext, CAMetalLayer *met @interface PPSSPPViewController () { GraphicsContext *graphicsContext; +#if !PPSSPP_PLATFORM(TVOS) CameraHelper *cameraHelper; LocationHelper *locationHelper; TouchTracker g_touchTracker; +#endif } -@property (strong, nonatomic) NSOperationQueue *accelerometerQueue; @property (nonatomic) GCController *gameController; +#if !PPSSPP_PLATFORM(TVOS) +@property (strong, nonatomic) NSOperationQueue *accelerometerQueue; @property (strong, nonatomic) CMMotionManager *motionManager; +#endif @end @implementation PPSSPPViewController { +#if !PPSSPP_PLATFORM(TVOS) UIScreenEdgePanGestureRecognizer *mBackGestureRecognizer; +#endif CGSize lastLayoutSize_; KeyboardInputView *keyboardInputView_; } +#if !PPSSPP_PLATFORM(TVOS) + // Strange idiom for generating unique IDs (within the process, at least). static const void *kPickerFilenameKey = &kPickerFilenameKey; static const void *kPickerRequestIdKey = &kPickerRequestIdKey; @@ -180,6 +190,7 @@ static int GetPickerRequestId(id picker) { g_requestManager.PostSystemFailure(requestId); } } +#endif - (id)init { self = [super init]; @@ -190,6 +201,10 @@ static int GetPickerRequestId(id picker) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidConnect:) name:GCControllerDidConnectNotification object:nil]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerDidDisconnect:) name:GCControllerDidDisconnectNotification object:nil]; +#if PPSSPP_PLATFORM(TVOS) + // Already the default. Should become YES at the top level menu, so the Menu button goes home. + self.controllerUserInteractionEnabled = NO; +#else // Observe orientation changes [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onOrientationChanged) @@ -199,6 +214,7 @@ static int GetPickerRequestId(id picker) { self.accelerometerQueue = [[NSOperationQueue alloc] init]; self.accelerometerQueue.name = @"AccelerometerQueue"; self.accelerometerQueue.maxConcurrentOperationCount = 1; +#endif } return self; } @@ -268,6 +284,7 @@ static int GetPickerRequestId(id picker) { exitRenderLoop = false; } +#if !PPSSPP_PLATFORM(TVOS) - (BOOL)prefersHomeIndicatorAutoHidden { if (g_Config.iAppSwitchMode == (int)AppSwitchMode::DOUBLE_SWIPE_INDICATOR) { return NO; @@ -275,8 +292,10 @@ static int GetPickerRequestId(id picker) { return YES; } } +#endif - (void)didBecomeActive { +#if !PPSSPP_PLATFORM(TVOS) if (self.motionManager.accelerometerAvailable) { self.motionManager.accelerometerUpdateInterval = 1.0 / 60.0; INFO_LOG(Log::G3D, "Starting accelerometer updates."); @@ -292,6 +311,7 @@ static int GetPickerRequestId(id picker) { } else { INFO_LOG(Log::G3D, "No accelerometer available, not starting updates."); } +#endif // Spin up the render thread, which in turn starts the emu thread. [self updateResolutionWithView:self.view]; @@ -301,11 +321,13 @@ static int GetPickerRequestId(id picker) { - (void)willResignActive { [self requestExitVulkanRenderLoop]; +#if !PPSSPP_PLATFORM(TVOS) // Stop accelerometer updates if (self.motionManager.accelerometerActive) { INFO_LOG(Log::G3D, "Stopping accelerometer updates"); [self.motionManager stopAccelerometerUpdates]; } +#endif } - (void)appWillTerminate:(NSNotification *)notification { @@ -317,7 +339,9 @@ static int GetPickerRequestId(id picker) { INFO_LOG(Log::G3D, "viewDidAppear"); // For hardware keyboard presses. [self becomeFirstResponder]; +#if !PPSSPP_PLATFORM(TVOS) [self updateGesture]; +#endif // This needs to be called really late during startup, unfortunately. #if PPSSPP_PLATFORM(IOS_APP_STORE) @@ -329,6 +353,7 @@ static int GetPickerRequestId(id picker) { #endif // IOS_APP_STORE } +#if !PPSSPP_PLATFORM(TVOS) // Enables tapping for edge area. -(UIRectEdge)preferredScreenEdgesDeferringSystemGestures { if (GetUIState() == UISTATE_INGAME) { @@ -343,11 +368,18 @@ static int GetPickerRequestId(id picker) { return UIRectEdgeTop | UIRectEdgeLeft | UIRectEdgeRight; } } +#endif - (void)controllerDidConnect:(NSNotification *)note { if (![[GCController controllers] containsObject:self.gameController]) self.gameController = nil; +#if PPSSPP_PLATFORM(TVOS) + if (InitSiriRemote((GCController *)note.object)) { + return; + } +#endif + if (self.gameController != nil) return; // already have a connected controller [self setupController:(GCController *)note.object]; @@ -358,10 +390,7 @@ static int GetPickerRequestId(id picker) { if (self.gameController == note.object) { ShutdownController(self.gameController); self.gameController = nil; - - if ([[GCController controllers] count] > 0) { - [self setupController:[[GCController controllers] firstObject]]; - } + [self setupFirstController]; } } @@ -372,6 +401,21 @@ static int GetPickerRequestId(id picker) { } } +// Picks the first gamepad. On tvOS, the Siri Remote is in the list too, and gets set up separately. +- (void)setupFirstController { + for (GCController *controller in [GCController controllers]) { +#if PPSSPP_PLATFORM(TVOS) + if (InitSiriRemote(controller)) { + continue; + } +#endif + if (self.gameController == nil) { + [self setupController:controller]; + } + } +} + +#if !PPSSPP_PLATFORM(TVOS) - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { g_touchTracker.Began(touches, self.view); @@ -391,6 +435,24 @@ static int GetPickerRequestId(id picker) { { g_touchTracker.Cancelled(touches, self.view); } +#endif + +#if PPSSPP_PLATFORM(TVOS) + +- (void)pickPhoto:(NSString *)saveFilename requestId:(int)requestId { + g_requestManager.PostSystemFailure(requestId); +} + +- (void)uiStateChanged { + [self hideKeyboard]; +} + +- (void)startVideo:(int)width height:(int)height {} +- (void)stopVideo {} +- (void)startLocation {} +- (void)stopLocation {} + +#else - (void)pickPhoto:(NSString *)saveFilename requestId:(int)requestId { NSLog(@"Picking photo to save to %@ (id: %d)", saveFilename, requestId); @@ -500,6 +562,8 @@ static int GetPickerRequestId(id picker) { 0 /* bearing */); } +#endif // PPSSPP_PLATFORM(TVOS) + - (void)loadView { INFO_LOG(Log::G3D, "Creating metal view"); // The view gets auto-resized later. @@ -511,6 +575,7 @@ static int GetPickerRequestId(id picker) { INFO_LOG(Log::System, "viewDidLoad"); +#if !PPSSPP_PLATFORM(TVOS) self.view.multipleTouchEnabled = YES; cameraHelper = [[CameraHelper alloc] init]; @@ -520,6 +585,7 @@ static int GetPickerRequestId(id picker) { [locationHelper setDelegate:self]; self.motionManager = [[CMMotionManager alloc] init]; +#endif keyboardInputView_ = [[KeyboardInputView alloc] initWithFrame:CGRectZero]; [self.view addSubview:keyboardInputView_]; @@ -535,9 +601,7 @@ static int GetPickerRequestId(id picker) { [self updateResolutionWithView:self.view]; - if ([[GCController controllers] count] > 0) { - [self setupController:[[GCController controllers] firstObject]]; - } + [self setupFirstController]; INFO_LOG(Log::G3D, "Detected size: %dx%d", g_display.pixel_xres, g_display.pixel_yres); } @@ -550,9 +614,11 @@ static int GetPickerRequestId(id picker) { if (scene) { self.view.contentScaleFactor = scene.screen.nativeScale; } +#if !PPSSPP_PLATFORM(TVOS) if (@available(iOS 16.0, *)) { [self setNeedsUpdateOfSupportedInterfaceOrientations]; } +#endif } extern float g_safeInsetLeft; @@ -598,16 +664,23 @@ extern float g_safeInsetBottom; } - (void)shareItems:(NSArray *)items { +#if PPSSPP_PLATFORM(TVOS) + // No share sheet on tvOS. SYSPROP_SUPPORTS_SHARE_* is false there, so this shouldn't be reached. + WARN_LOG(Log::System, "Sharing isn't available on tvOS"); +#else UIActivityViewController *viewController = [[UIActivityViewController alloc] initWithActivityItems:items applicationActivities:nil]; // On iPad, the share sheet is a popover and needs an anchor. viewController.popoverPresentationController.sourceView = self.view; viewController.popoverPresentationController.sourceRect = CGRectMake(CGRectGetMidX(self.view.bounds), CGRectGetMidY(self.view.bounds), 0, 0); viewController.popoverPresentationController.permittedArrowDirections = 0; [self presentViewController:viewController animated:YES completion:nil]; +#endif } - (void)appSwitchModeChanged { +#if !PPSSPP_PLATFORM(TVOS) [self setNeedsUpdateOfHomeIndicatorAutoHidden]; +#endif } // Main thread, like the rest of these. @@ -643,6 +716,13 @@ extern float g_safeInsetBottom; #pragma mark - Status Bar Control +#if PPSSPP_PLATFORM(TVOS) + +// No status bar or rotation on tvOS. +- (void)immersiveModeChanged {} + +#else + // The immersive mode setting is per-orientation, so we need to know which way we're facing. // Can't just use g_display for this, since it lags behind during rotation. // Note: Using viewIfLoaded, since this can get called before the view exists, and we don't want to force it into existence. @@ -682,6 +762,8 @@ extern float g_safeInsetBottom; [self setNeedsStatusBarAppearanceUpdate]; } +#endif // PPSSPP_PLATFORM(TVOS) + // Rotations and window resizes land here. UIKit lays out at the final size at the start of a // rotation animation, so resizing here (rather than when the rotation completes) keeps the // backbuffer matching the view while it animates. @@ -692,9 +774,11 @@ extern float g_safeInsetBottom; return; } lastLayoutSize_ = size; +#if !PPSSPP_PLATFORM(TVOS) if (self.view.window.windowScene) { [cameraHelper setInterfaceOrientation:self.view.window.windowScene.interfaceOrientation]; } +#endif INFO_LOG(Log::G3D, "Laid out at %dx%d", (int)size.width, (int)size.height); // Resized in place: NativeFrame picks up the new size and lets the graphics context resize. [self updateResolutionWithView:self.view]; @@ -715,7 +799,12 @@ extern float g_safeInsetBottom; // The bounds are already corrected for orientation. CGSize size = view.bounds.size; +#if PPSSPP_PLATFORM(TVOS) + // Seen from the couch. A 1920x1080 point screen comes out as 960x540 dp. + float dpi = 480.0f * scale; +#else float dpi = (IS_IPAD() ? 200.0f : 150.0f) * scale; +#endif PendingResolution res{ (float)(size.width * scale), (float)(size.height * scale), 240.0f / dpi, diff --git a/ios/iOSCoreAudio.mm b/ios/iOSCoreAudio.mm index a118ca167d..6522524b39 100644 --- a/ios/iOSCoreAudio.mm +++ b/ios/iOSCoreAudio.mm @@ -55,6 +55,14 @@ void iOSCoreAudioUpdateSession() { NSError *error = nil; INFO_LOG(Log::Audio, "RespectSilentMode: %d MixWithOthers: %d Recording: %d", g_Config.bAudioRespectSilentMode, g_Config.bAudioMixWithOthers, g_micEnabled); +#if PPSSPP_PLATFORM(TVOS) + // No microphone, and no silent switch. + if (g_Config.bAudioMixWithOthers) { + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:AVAudioSessionCategoryOptionMixWithOthers error:&error]; + } else { + [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback withOptions:0 error:&error]; + } +#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]; @@ -82,6 +90,7 @@ void iOSCoreAudioUpdateSession() { } // Can't achieve exclusive + respect silent mode } +#endif if (error) { NSLog(@"%@", error); @@ -327,6 +336,10 @@ static void UpdateMicrophone() { // Calls back on the main thread. static void WithRecordPermission(void (^then)(bool granted)) { +#if PPSSPP_PLATFORM(TVOS) + // No microphone. System_AudioRecordingIsAvailable() says so, so we shouldn't get here. + then(false); +#else if (@available(iOS 17.0, *)) { switch ([AVAudioApplication sharedInstance].recordPermission) { case AVAudioApplicationRecordPermissionGranted: @@ -361,6 +374,7 @@ static void WithRecordPermission(void (^then)(bool granted)) { break; } } +#endif } void iOSCoreAudioStartRecording(int sampleRate) { diff --git a/ios/main.mm b/ios/main.mm index fe60216ae1..d6ddbf5167 100644 --- a/ios/main.mm +++ b/ios/main.mm @@ -40,8 +40,9 @@ #include "Common/Log/LogManager.h" #include "Core/Util/DarwinFileSystemServices.h" -// Compile out all the hackery in app store builds. -#if !PPSSPP_PLATFORM(IOS_APP_STORE) +// Compile out all the hackery in app store builds. tvOS doesn't have posix_spawn, and there's no +// jailbreak scene to speak of. +#if !PPSSPP_PLATFORM(IOS_APP_STORE) && !PPSSPP_PLATFORM(TVOS) struct cs_blob_index { uint32_t type; @@ -251,7 +252,11 @@ static std::string version; std::string System_GetProperty(SystemProperty prop) { switch (prop) { case SYSPROP_NAME: +#if PPSSPP_PLATFORM(TVOS) + return StringFromFormat("tvOS %s", version.c_str()); +#else return StringFromFormat("iOS %s", version.c_str()); +#endif case SYSPROP_LANGREGION: return [[[NSLocale currentLocale] objectForKey:NSLocaleIdentifier] UTF8String]; case SYSPROP_BUILD_VERSION: @@ -269,20 +274,28 @@ std::vector System_GetPropertyStringVec(SystemProperty prop) { } } +#if !PPSSPP_PLATFORM(TVOS) extern "C" { int Apple_GetCurrentBatteryCapacity(); } +#endif int64_t System_GetPropertyInt(SystemProperty prop) { switch (prop) { case SYSPROP_AUDIO_SAMPLE_RATE: return 44100; case SYSPROP_DEVICE_TYPE: +#if PPSSPP_PLATFORM(TVOS) + return DEVICE_TYPE_TV; +#else return DEVICE_TYPE_MOBILE; +#endif case SYSPROP_SYSTEMVERSION: return g_iosVersionMajor; +#if !PPSSPP_PLATFORM(TVOS) case SYSPROP_BATTERY_PERCENTAGE: return Apple_GetCurrentBatteryCapacity(); +#endif default: return -1; } @@ -306,6 +319,31 @@ float System_GetPropertyFloat(SystemProperty prop) { } bool System_GetPropertyBool(SystemProperty prop) { +#if PPSSPP_PLATFORM(TVOS) + // No document picker, share sheet, clipboard, camera, location, motion, microphone, battery, + // rotation or web browser. And no files we didn't put there ourselves, so no file browsing either. + switch (prop) { + case SYSPROP_HAS_FILE_BROWSER: + case SYSPROP_HAS_FOLDER_BROWSER: + case SYSPROP_HAS_IMAGE_BROWSER: + case SYSPROP_HAS_ACCELEROMETER: + case SYSPROP_HAS_KEYBOARD: + case SYSPROP_KEYBOARD_IS_SOFT: + case SYSPROP_SUPPORTS_SHARE_TEXT: + case SYSPROP_SUPPORTS_SHARE_FILE: + case SYSPROP_CAN_LAUNCH_URL: + case SYSPROP_DISPLAY_HAS_CAMERA_CUTOUT: + case SYSPROP_CAN_READ_BATTERY_PERCENTAGE: + case SYSPROP_CAN_RESTRICT_ORIENTATION: + case SYSPROP_MICROPHONE_NEEDS_POLLING: + return false; + case SYSPROP_HAS_BACK_BUTTON: + // The remote's Menu/Back button. + return true; + default: + break; + } +#endif switch (prop) { case SYSPROP_HAS_DEEP_LINKS: return true; @@ -406,6 +444,7 @@ void System_Notify(SystemNotification notification) { iOSCoreAudioUpdateSession(); }); break; +#if !PPSSPP_PLATFORM(TVOS) case SystemNotification::ROTATE_UPDATED: dispatch_async(dispatch_get_main_queue(), ^{ if (sharedViewController) { @@ -418,12 +457,29 @@ void System_Notify(SystemNotification notification) { } }); break; +#endif default: break; } } bool System_MakeRequest(SystemRequestType type, int requestId, const std::string ¶m1, const std::string ¶m2, int64_t param3, int64_t param4) { +#if PPSSPP_PLATFORM(TVOS) + switch (type) { + case SystemRequestType::BROWSE_FOR_FILE: + case SystemRequestType::BROWSE_FOR_FOLDER: + case SystemRequestType::BROWSE_FOR_IMAGE: + case SystemRequestType::CAMERA_COMMAND: + case SystemRequestType::GPS_COMMAND: + case SystemRequestType::MICROPHONE_COMMAND: + case SystemRequestType::SHARE_TEXT: + case SystemRequestType::SHARE_FILE: + case SystemRequestType::COPY_TO_CLIPBOARD: + return false; + default: + break; + } +#endif switch (type) { case SystemRequestType::RESTART_APP: { @@ -583,6 +639,7 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string return true; } #endif +#if !PPSSPP_PLATFORM(TVOS) case SystemRequestType::COPY_TO_CLIPBOARD: { NSString *text = [NSString stringWithUTF8String:param1.c_str()]; @@ -591,6 +648,7 @@ bool System_MakeRequest(SystemRequestType type, int requestId, const std::string }); return true; } +#endif case SystemRequestType::SET_KEEP_SCREEN_BRIGHT: dispatch_async(dispatch_get_main_queue(), ^{ INFO_LOG(Log::System, "SET_KEEP_SCREEN_BRIGHT: %d", (int)param3); @@ -619,8 +677,12 @@ void System_LaunchUrl(LaunchUrlType urlType, std::string_view url) { } bool System_AudioRecordingIsAvailable() { +#if PPSSPP_PLATFORM(TVOS) + return false; +#else // Every iPhone and iPad has a microphone. Permission is asked for when recording starts. return true; +#endif } PermissionStatus System_GetPermissionStatus(SystemPermission permission) { @@ -628,6 +690,7 @@ PermissionStatus System_GetPermissionStatus(SystemPermission permission) { } void System_Vibrate(int mode) { +#if !PPSSPP_PLATFORM(TVOS) // Comes from the UI code on the emu thread, and UIKit wants the main thread. dispatch_async(dispatch_get_main_queue(), ^{ // Does nothing on devices without a Taptic Engine. @@ -638,8 +701,16 @@ void System_Vibrate(int mode) { } [feedbackGenerator selectionChanged]; }); +#endif } +#if PPSSPP_PLATFORM(TVOS) +// The iOS one is in CameraHelper.mm. +std::vector System_GetCameraDeviceList() { + return std::vector(); +} +#endif + AudioBackend *System_CreateAudioBackend() { // Use legacy mechanisms. return nullptr; @@ -654,7 +725,7 @@ int main(int argc, char *argv[]) { g_logManager.EnableOutput(LogOutput::Stdio); -#if PPSSPP_PLATFORM(IOS_APP_STORE) +#if PPSSPP_PLATFORM(IOS_APP_STORE) || PPSSPP_PLATFORM(TVOS) g_jitAvailable = false; #else // Hacky hacks to try to enable JIT by pretending to be a debugger. diff --git a/ios/tvOS-Info.plist b/ios/tvOS-Info.plist new file mode 100644 index 0000000000..50001dc109 --- /dev/null +++ b/ios/tvOS-Info.plist @@ -0,0 +1,71 @@ + + + + + NSLocalNetworkUsageDescription + Your local network needs to be accessed by PPSSPP for network multiplayer support. + CFBundleDisplayName + PPSSPP + CFBundleExecutable + PPSSPP + CFBundleIdentifier + org.ppsspp.ppsspp + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + PPSSPP + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + CFBundleURLTypes + + + CFBundleURLName + org.ppsspp.ppsspp + CFBundleURLSchemes + + ppsspp + + + + LSRequiresIPhoneOS + + UIRequiredDeviceCapabilities + + arm64 + + LSApplicationCategoryType + public.app-category.games + GCSupportedGameControllers + + + ProfileName + ExtendedGamepad + + + ProfileName + MicroGamepad + + + UIApplicationSceneManifest + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + SceneDelegate + + + + + +