From 0f4671127fc4198ccfec5bdaafbd79b3d13a029b Mon Sep 17 00:00:00 2001 From: polyxneg Date: Sun, 5 Jul 2026 10:13:42 +0200 Subject: [PATCH] feat: Added library export functionaltiy via urlscheme --- ios/AppDelegate.h | 5 ++ ios/AppDelegate.mm | 107 +++++++++++++++++++++++++++++++++++++++++++ ios/SceneDelegate.mm | 45 +++++++++++++++++- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/ios/AppDelegate.h b/ios/AppDelegate.h index 6d81b8b348..4a08b20a37 100644 --- a/ios/AppDelegate.h +++ b/ios/AppDelegate.h @@ -15,6 +15,11 @@ - (BOOL)launchPPSSPP:(int)argc argv:(char**)argv; - (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=", and +// PPSSPP responds by opening "://ppsspp?games=". +- (void)exportLibraryToScheme:(NSString *)callerScheme; + @end void copyDeepLinkForPath(std::string_view filePath); diff --git a/ios/AppDelegate.mm b/ios/AppDelegate.mm index db3fe9550c..beab1370e9 100644 --- a/ios/AppDelegate.mm +++ b/ios/AppDelegate.mm @@ -6,12 +6,20 @@ #import "Core/System.h" #import "Core/Config.h" #import "Core/Util/PathUtil.h" +#import "Core/Util/RecentFiles.h" #import "Common/Log.h" +#import "Common/File/Path.h" +#import "Common/File/DirListing.h" +#import "Common/File/FileUtil.h" #import "IAPManager.h" #import #import +#include +#include +#include + // TODO: Unfortunate hack to force link SceneDelegate class. // 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. @@ -121,6 +129,105 @@ __attribute__((used)) static Class _forceLinkSceneDelegate = [SceneDelegate clas 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 GatherGameLibrary() { + std::vector paths; + std::set 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 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=" deep link from it. + std::vector library = GatherGameLibrary(); + + NSMutableArray *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 { switch (g_Config.iScreenRotation) { case ROTATION_LOCKED_HORIZONTAL: diff --git a/ios/SceneDelegate.mm b/ios/SceneDelegate.mm index c1e52a8948..e2b1cfb0e5 100644 --- a/ios/SceneDelegate.mm +++ b/ios/SceneDelegate.mm @@ -35,8 +35,29 @@ static NSString *ExtractDeepLinkPath(NSURL *url) { return nil; } +// Detects a library-export request of the form "ppsspp://gameInfo?scheme=" +// 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 () @property (nonatomic, strong) NSString *pendingLaunchPath; +@property (nonatomic, strong) NSString *pendingExportScheme; @end @implementation SceneDelegate @@ -47,11 +68,18 @@ static NSString *ExtractDeepLinkPath(NSURL *url) { - (void)capturePendingPathFromURLContexts:(NSSet *)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); if (path.length > 0) { self.pendingLaunchPath = path; 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; for (UIOpenURLContext *context in URLContexts) { 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); if (path.length > 0) { [appDelegate processFilePath:path]; @@ -118,6 +153,14 @@ static NSString *ExtractDeepLinkPath(NSURL *url) { NSString *bundlePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingString:@"/assets/"]; 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] initWithFrame:[[UIScreen mainScreen] bounds]];