feat: Added library export functionaltiy via urlscheme

This commit is contained in:
polyxneg
2026-07-05 10:13:42 +02:00
parent 56c694d88b
commit 0f4671127f
3 changed files with 156 additions and 1 deletions
+5
View File
@@ -15,6 +15,11 @@
- (BOOL)launchPPSSPP:(int)argc argv:(char**)argv; - (BOOL)launchPPSSPP:(int)argc argv:(char**)argv;
- (void)processFilePath:(NSString *)path; - (void)processFilePath:(NSString *)path;
// Exports the game library to a caller app via a URL scheme callback.
// The caller requests it with "ppsspp://gameInfo?scheme=<callerScheme>", and
// PPSSPP responds by opening "<callerScheme>://ppsspp?games=<base64url-json>".
- (void)exportLibraryToScheme:(NSString *)callerScheme;
@end @end
void copyDeepLinkForPath(std::string_view filePath); void copyDeepLinkForPath(std::string_view filePath);
+107
View File
@@ -6,12 +6,20 @@
#import "Core/System.h" #import "Core/System.h"
#import "Core/Config.h" #import "Core/Config.h"
#import "Core/Util/PathUtil.h" #import "Core/Util/PathUtil.h"
#import "Core/Util/RecentFiles.h"
#import "Common/Log.h" #import "Common/Log.h"
#import "Common/File/Path.h"
#import "Common/File/DirListing.h"
#import "Common/File/FileUtil.h"
#import "IAPManager.h" #import "IAPManager.h"
#import <AVFoundation/AVFoundation.h> #import <AVFoundation/AVFoundation.h>
#import <objc/runtime.h> #import <objc/runtime.h>
#include <set>
#include <string>
#include <vector>
// TODO: Unfortunate hack to force link SceneDelegate class. // TODO: Unfortunate hack to force link SceneDelegate class.
// This is necessary because otherwise classes from static libraries aren't loaded. // This is necessary because otherwise classes from static libraries aren't loaded.
// We should move all the iOS code into the main binary directly to avoid this problem. // We should move all the iOS code into the main binary directly to avoid this problem.
@@ -121,6 +129,105 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas
System_PostUIMessage(UIMessage::REQUEST_GAME_BOOT, gamePath.ToString()); System_PostUIMessage(UIMessage::REQUEST_GAME_BOOT, gamePath.ToString());
} }
// Gathers the game library as a flat list of file paths. This mirrors what the
// user sees in the "Games" tab. Duplicates are
// removed while preserving order.
static std::vector<std::string> GatherGameLibrary() {
std::vector<std::string> paths;
std::set<std::string> seen;
auto addPath = [&](const std::string &p) {
if (p.empty()) {
return;
}
if (seen.insert(p).second) {
paths.push_back(p);
}
};
const Path &gamesDir = g_Config.currentDirectory;
if (!gamesDir.empty()) {
std::vector<File::FileInfo> fileInfo;
if (File::GetFilesInDir(gamesDir, &fileInfo, "iso:cso:chd:pbp:elf:prx:ppdmp:")) {
for (const File::FileInfo &info : fileInfo) {
if (info.isDirectory) {
// Detect installed/extracted PSP game folders.
if (File::Exists(info.fullName / "EBOOT.PBP") ||
File::Exists(info.fullName / "PSP_GAME/SYSDIR")) {
addPath(info.fullName.ToString());
}
} else {
addPath(info.fullName.ToString());
}
}
}
}
// Also include recently played games.
for (const std::string &recent : g_recentFiles.GetRecentFiles()) {
addPath(recent);
}
return paths;
}
- (void)exportLibraryToScheme:(NSString *)callerScheme {
if (callerScheme.length == 0) {
NSLog(@"exportLibraryToScheme: empty caller scheme, ignoring");
return;
}
// Build a list of games. Each game's "titleId" is set to the file path so
// the receiving app can build a "ppsspp://open?path=<path>" deep link from it.
std::vector<std::string> library = GatherGameLibrary();
NSMutableArray<NSDictionary *> *games = [NSMutableArray arrayWithCapacity:library.size()];
for (const std::string &file : library) {
NSString *path = [[NSString alloc] initWithBytes:file.data()
length:file.length()
encoding:NSUTF8StringEncoding];
if (path.length == 0) {
continue;
}
// Create a human-readable title from the filename (without extension).
NSString *titleName = [[path lastPathComponent] stringByDeletingPathExtension];
if (titleName.length == 0) {
titleName = [path lastPathComponent];
}
[games addObject:@{
@"titleName": titleName ?: @"",
@"titleId": path,
@"developer": @"",
@"version": @"",
}];
}
NSError *error = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:games options:0 error:&error];
if (!jsonData || error) {
NSLog(@"exportLibraryToScheme: failed to serialize library: %@", error);
return;
}
// base64url-encode (URL-safe alphabet, no padding) to keep it URL-safe.
NSString *encoded = [jsonData base64EncodedStringWithOptions:0];
encoded = [encoded stringByReplacingOccurrencesOfString:@"+" withString:@"-"];
encoded = [encoded stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
encoded = [encoded stringByReplacingOccurrencesOfString:@"=" withString:@""];
NSString *urlString = [NSString stringWithFormat:@"%@://ppsspp?games=%@", callerScheme, encoded];
NSURL *returnURL = [NSURL URLWithString:urlString];
if (!returnURL) {
NSLog(@"exportLibraryToScheme: failed to build return URL for scheme '%@'", callerScheme);
return;
}
NSLog(@"exportLibraryToScheme: returning %lu games to scheme '%@'", (unsigned long)games.count, callerScheme);
dispatch_async(dispatch_get_main_queue(), ^{
[[UIApplication sharedApplication] openURL:returnURL options:@{} completionHandler:nil];
});
}
- (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window { - (UIInterfaceOrientationMask)application:(UIApplication *)application supportedInterfaceOrientationsForWindow:(UIWindow *)window {
switch (g_Config.iScreenRotation) { switch (g_Config.iScreenRotation) {
case ROTATION_LOCKED_HORIZONTAL: case ROTATION_LOCKED_HORIZONTAL:
+44 -1
View File
@@ -35,8 +35,29 @@ static NSString *ExtractDeepLinkPath(NSURL *url) {
return nil; return nil;
} }
// Detects a library-export request of the form "ppsspp://gameInfo?scheme=<callerScheme>"
// and returns the caller's URL scheme to respond to (or nil if it's not one).
static NSString *ExtractGameInfoScheme(NSURL *url) {
if (![[url scheme] isEqualToString:@"ppsspp"]) {
return nil;
}
if (![[url host] isEqualToString:@"gameInfo"]) {
return nil;
}
NSURLComponents *components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
for (NSURLQueryItem *item in components.queryItems) {
if ([item.name isEqualToString:@"scheme"] && item.value.length > 0) {
return item.value;
}
}
return nil;
}
@interface SceneDelegate () @interface SceneDelegate ()
@property (nonatomic, strong) NSString *pendingLaunchPath; @property (nonatomic, strong) NSString *pendingLaunchPath;
@property (nonatomic, strong) NSString *pendingExportScheme;
@end @end
@implementation SceneDelegate @implementation SceneDelegate
@@ -47,11 +68,18 @@ static NSString *ExtractDeepLinkPath(NSURL *url) {
- (void)capturePendingPathFromURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts { - (void)capturePendingPathFromURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts {
for (UIOpenURLContext *context in URLContexts) { for (UIOpenURLContext *context in URLContexts) {
NSString *exportScheme = ExtractGameInfoScheme(context.URL);
if (exportScheme.length > 0) {
// The library export is performed after PPSSPP has initialized (and loaded its config)
self.pendingExportScheme = exportScheme;
NSLog(@"SceneDelegate: captured cold-start gameInfo export scheme: %@", exportScheme);
continue;
}
NSString *path = ExtractDeepLinkPath(context.URL); NSString *path = ExtractDeepLinkPath(context.URL);
if (path.length > 0) { if (path.length > 0) {
self.pendingLaunchPath = path; self.pendingLaunchPath = path;
NSLog(@"SceneDelegate: captured cold-start deep link path: %@", self.pendingLaunchPath); NSLog(@"SceneDelegate: captured cold-start deep link path: %@", self.pendingLaunchPath);
break;
} }
} }
} }
@@ -60,6 +88,13 @@ static NSString *ExtractDeepLinkPath(NSURL *url) {
AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate; AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate;
for (UIOpenURLContext *context in URLContexts) { for (UIOpenURLContext *context in URLContexts) {
NSLog(@"SceneDelegate: openURLContexts called with URL: %@", context.URL); NSLog(@"SceneDelegate: openURLContexts called with URL: %@", context.URL);
NSString *exportScheme = ExtractGameInfoScheme(context.URL);
if (exportScheme.length > 0) {
[appDelegate exportLibraryToScheme:exportScheme];
continue;
}
NSString *path = ExtractDeepLinkPath(context.URL); NSString *path = ExtractDeepLinkPath(context.URL);
if (path.length > 0) { if (path.length > 0) {
[appDelegate processFilePath:path]; [appDelegate processFilePath:path];
@@ -118,6 +153,14 @@ static NSString *ExtractDeepLinkPath(NSURL *url) {
NSString *bundlePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingString:@"/assets/"]; NSString *bundlePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingString:@"/assets/"];
NativeInit(argc, (const char**)argv, documentsPath.UTF8String, bundlePath.UTF8String, NULL); NativeInit(argc, (const char**)argv, documentsPath.UTF8String, bundlePath.UTF8String, NULL);
// If we were cold-started by a library export request, serve it now that
// the config (and recent games list) has been loaded by NativeInit.
if (self.pendingExportScheme.length > 0) {
NSString *exportScheme = self.pendingExportScheme;
self.pendingExportScheme = nil;
[appDelegate exportLibraryToScheme:exportScheme];
}
self.window = [[UIWindow alloc] initWithWindowScene:self.windowScene]; self.window = [[UIWindow alloc] initWithWindowScene:self.windowScene];
// self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]]; // self.window = [[UIWindow alloc] initWithFrame:[[UIScreen mainScreen] bounds]];