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 <noreply@anthropic.com>
This commit is contained in:
Henrik RydgårdandClaude Opus 5.5 committed 2026-10-13 01:06:55 +02:00
1 parent 20d1a58806
commit 3a8a1799ee
13 files changed
+412 -32

No files matched your search

+42 -10
View File
@@ -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
+19 -7
View File
@@ -34,10 +34,6 @@
#include <UIKit/UIKit.h>
#include <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
@interface DocumentPickerDelegate : NSObject <UIDocumentPickerDelegate>
@property DarwinDirectoryPanelCallback panelCallback;
@end
static std::map<std::string, void*> 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 <UIDocumentPickerDelegate>
@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<NSURL *> *)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);
+3
View File
@@ -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")
+5 -1
View File
@@ -214,6 +214,7 @@ static std::vector<std::string> 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<std::string> 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);
+11
View File
@@ -2,8 +2,12 @@
#include <string_view>
#include "ppsspp_config.h"
#import <GameController/GameController.h>
#if !PPSSPP_PLATFORM(TVOS)
#import <CoreMotion/CoreMotion.h>
#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<UIPress *> *presses, UIPressesEvent *event);
+54
View File
@@ -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);
+5
View File
@@ -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];
+15 -1
View File
@@ -1,10 +1,22 @@
#pragma once
#include "ppsspp_config.h"
#import <UIKit/UIKit.h>
#import <PhotosUI/PhotosUI.h>
#import <GameController/GameController.h>
#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 <PhotosUI/PhotosUI.h>
#import "CameraHelper.h"
#import "LocationHelper.h"
@@ -14,6 +26,8 @@
CameraFrameDelegate, LocationHandlerDelegate,
UIGestureRecognizerDelegate>
#endif
- (void)hideKeyboard;
- (void)showKeyboard;
- (void)shareText:(NSString *)text;
+98 -9
View File
@@ -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 <Photos/Photos.h>
#endif
#import <QuartzCore/CAMetalLayer.h>
#import <objc/runtime.h>
#include <atomic>
@@ -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,
+14
View File
@@ -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) {
+74 -3
View File
@@ -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<std::string> 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 &param1, const std::string &param2, 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<std::string> System_GetCameraDeviceList() {
return std::vector<std::string>();
}
#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.
+71
View File
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Your local network needs to be accessed by PPSSPP for network multiplayer support.</string>
<key>CFBundleDisplayName</key>
<string>PPSSPP</string>
<key>CFBundleExecutable</key>
<string>PPSSPP</string>
<key>CFBundleIdentifier</key>
<string>org.ppsspp.ppsspp</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>PPSSPP</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>org.ppsspp.ppsspp</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ppsspp</string>
</array>
</dict>
</array>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>LSApplicationCategoryType</key>
<string>public.app-category.games</string>
<key>GCSupportedGameControllers</key>
<array>
<dict>
<key>ProfileName</key>
<string>ExtendedGamepad</string>
</dict>
<dict>
<key>ProfileName</key>
<string>MicroGamepad</string>
</dict>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneConfigurationName</key>
<string>Default Configuration</string>
<key>UISceneDelegateClassName</key>
<string>SceneDelegate</string>
</dict>
</array>
</dict>
</dict>
</dict>
</plist>