Files
ppsspp/Core/Util/DarwinFileSystemServices.mm
T
Henrik RydgårdandClaude Opus 5.5 bcd6d250e6 Mac: Restore focus after the file panel's sheet is gone
Logging after the panel closed showed the app still active but no key window,
so keys beeped until the window was clicked. Making the window key in the
completion handler doesn't stick (the sheet is still attached then), so do it
on the next runloop pass. The Dock bounce isn't needed, and only
[NSApp activate] is kept, for the case where the app did lose activation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:25:05 +01:00

390 lines
14 KiB
Plaintext

//
// DarwinFileSystemServices.mm
// PPSSPP
//
// Created by Serena on 20/01/2023.
//
// NOTES:
// Files inside the app folder opened in iOS via the document picker end up with this path:
// /private/var/mobile/Containers/Data/Application/<UUID>/Documents
// However, when we query the home directory we get:
// /var/mobile/Containers/Data/Application/<UUID>/Documents
// The /private prefix seems to be optional, so we strip it off before returning it.
#include <dispatch/dispatch.h>
#include <CoreServices/CoreServices.h>
#include <map>
#include "ppsspp_config.h"
#include "Common/Log.h"
#include "Common/StringUtils.h"
#include "Core/Config.h"
#include "DarwinFileSystemServices.h"
#if !__has_feature(objc_arc)
#error Must be built with ARC, please revise the flags for DarwinFileSystemServices.mm to include -fobjc-arc.
#endif
#if __has_include(<UIKit/UIKit.h>)
#include "../ios/ViewController.h"
#include <UIKit/UIKit.h>
#include <UniformTypeIdentifiers/UniformTypeIdentifiers.h>
static std::map<std::string, void*> activeAccessTokens;
void *DarwinFileSystemServices::__pickerDelegate = nullptr;
void DarwinFileSystemServices::ClearDelegate() {
// TODO: Figure out how to free the delegate.
// CFRelease((__bridge DocumentPickerDelegate *)__pickerDelegate);
__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]) {
self.panelCallback = panelCallback;
}
return self;
}
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url) {
self.panelCallback(false, Path());
return;
}
addBookmark(url);
// 1. Start accessing the resource
BOOL success = [url startAccessingSecurityScopedResource];
if (success) {
// 2. Pass the path to PPSSPP
// You should call stopAccessingSecurityScopedResource
// when the file is closed, otherwise you "leak" kernel permissions.
self.panelCallback(true, Path(url.path.UTF8String));
} else {
ERROR_LOG(Log::System, "Failed to start accessing security scoped resource for: %s", url.path.UTF8String);
self.panelCallback(false, Path());
}
INFO_LOG(Log::System, "Callback processed, pre-emptively hide keyboard");
[sharedViewController hideKeyboard];
DarwinFileSystemServices::ClearDelegate();
}
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
self.panelCallback(false, Path());
INFO_LOG(Log::System, "Picker cancelled, pre-emptively hide keyboard");
[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.
static void addBookmark(NSURL *url) {
if (!url) return;
// IMPORTANT: You must start accessing BEFORE creating the bookmark,
// otherwise the OS denies the 'read' required to seal the bookmark.
BOOL accessStarted = [url startAccessingSecurityScopedResource];
INFO_LOG(Log::System, "Darwin: Creating bookmark (Access: %s) for: %s",
accessStarted ? "YES" : "NO", url.path.UTF8String);
NSError *error = nil;
#if PPSSPP_PLATFORM(IOS)
NSURLBookmarkCreationOptions options = 0;
#else
NSURLBookmarkCreationOptions options = NSURLBookmarkCreationWithSecurityScope;
#endif
NSData *bookmark = [url bookmarkDataWithOptions:options
includingResourceValuesForKeys:nil
relativeToURL:nil
error:&error];
if (bookmark) {
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSMutableDictionary *dict = [[defaults dictionaryForKey:@"SecureBookmarks"] mutableCopy] ?: [NSMutableDictionary dictionary];
[dict setObject:bookmark forKey:url.path];
[defaults setObject:dict forKey:@"SecureBookmarks"];
[defaults synchronize];
// If we started access specifically for this function, we keep the token.
// If we didn't have it in our map yet, store this URL.
std::string pathKey = url.path.UTF8String;
if (accessStarted && activeAccessTokens.find(pathKey) == activeAccessTokens.end()) {
activeAccessTokens[pathKey] = (void *)CFBridgingRetain(url);
} else if (accessStarted) {
// If it's already in the map, we don't need a second retain.
[url stopAccessingSecurityScopedResource];
}
} else {
ERROR_LOG(Log::System, "Darwin: Bookmark failed. Error 260 often means the file isn't 'coordinated' yet. Detail: %s",
error.localizedDescription.UTF8String);
if (accessStarted) [url stopAccessingSecurityScopedResource];
}
}
Path DarwinFileSystemServices::reauthorizeBookmarkByPath(const Path &pathStr) {
INFO_LOG(Log::System, "Darwin: Reauthorizing [%s]", pathStr.c_str());
// 1. Return immediately if already active
if (activeAccessTokens.count(pathStr.ToString())) {
return pathStr;
}
// 2. Retrieve bookmark from disk
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
NSDictionary *dict = [defaults dictionaryForKey:@"SecureBookmarks"];
NSData *bookmarkData = dict[@(pathStr.c_str())];
if (!bookmarkData) {
WARN_LOG(Log::System, "Darwin: No bookmark data found for path (%s)", pathStr.c_str());
return Path();
}
// 3. Resolve
BOOL isStale = NO;
NSError *error = nil;
#if PPSSPP_PLATFORM(IOS)
NSURLBookmarkResolutionOptions resOptions = 0;
#else
NSURLBookmarkResolutionOptions resOptions = NSURLBookmarkResolutionWithSecurityScope;
#endif
NSURL *url = [NSURL URLByResolvingBookmarkData:bookmarkData
options:resOptions
relativeToURL:nil
bookmarkDataIsStale:&isStale
error:&error];
if (url && [url startAccessingSecurityScopedResource]) {
Path resolvedPath(url.path.UTF8String);
// 4. Handle stale/moved files
if (isStale || resolvedPath != pathStr) {
INFO_LOG(Log::System, "Darwin: File moved! Updating [%s] -> [%s]", pathStr.c_str(), resolvedPath.c_str());
// Clean up the old key from defaults
NSMutableDictionary *mutableDict = [dict mutableCopy];
[mutableDict removeObjectForKey:@(pathStr.c_str())];
[defaults setObject:mutableDict forKey:@"SecureBookmarks"];
// Re-save under the new path
addBookmark(url);
} else {
// Path is the same, just cache the token
activeAccessTokens[resolvedPath.ToString()] = (void *)CFBridgingRetain(url);
}
return resolvedPath;
}
ERROR_LOG(Log::System, "Darwin: Resolution failed for %s", pathStr.c_str());
return Path();
}
void DarwinFileSystemServices::stopAccessingPath(const Path &pathStr) {
auto it = activeAccessTokens.find(pathStr.ToString());
if (it != activeAccessTokens.end()) {
INFO_LOG(Log::System, "Darwin: Stopping access for: %s", pathStr.c_str());
NSURL *url = (__bridge_transfer NSURL *)it->second;
[url stopAccessingSecurityScopedResource];
activeAccessTokens.erase(it);
}
}
void DarwinFileSystemServices::terminate() {
INFO_LOG(Log::System, "Darwin: Terminating all security-scoped access.");
for (auto const& [path, token] : activeAccessTokens) {
NSURL *url = (__bridge_transfer NSURL *)token;
[url stopAccessingSecurityScopedResource];
}
activeAccessTokens.clear();
}
#else
#include <AppKit/AppKit.h>
#endif // __has_include(<UIKit/UIKit.h>)
#if PPSSPP_PLATFORM(MAC)
static int g_openPanels = 0; // Only touched on the main thread.
bool DarwinIsPanelOpen() {
return g_openPanels > 0;
}
void DarwinPresentPanel(NSSavePanel *panel, void (^handler)(NSModalResponse response)) {
NSWindow *parent = [NSApp keyWindow] ?: [NSApp mainWindow];
g_openPanels++;
void (^done)(NSModalResponse) = ^(NSModalResponse response) {
g_openPanels--;
handler(response);
// The sheet is still attached while this runs, so restore focus once it's gone.
dispatch_async(dispatch_get_main_queue(), ^{
if (![NSApp isActive]) {
if (@available(macOS 14.0, *)) {
[NSApp activate];
} else {
[NSApp activateIgnoringOtherApps:YES];
}
}
if (parent) {
[parent makeKeyAndOrderFront:nil];
}
});
};
if (parent && !parent.attachedSheet) {
[panel beginSheetModalForWindow:parent completionHandler:done];
} else {
// No window to attach to (or it already has a sheet): show it on its own.
[panel beginWithCompletionHandler:done];
}
}
#endif
void DarwinFileSystemServices::presentDirectoryPanel(
DarwinDirectoryPanelCallback panelCallback,
bool allowFiles, bool allowDirectories,
BrowseFileType fileType) {
dispatch_async(dispatch_get_main_queue(), ^{
#if PPSSPP_PLATFORM(MAC)
NSOpenPanel *panel = [[NSOpenPanel alloc] init];
panel.allowsMultipleSelection = NO;
panel.canChooseFiles = allowFiles;
panel.canChooseDirectories = allowDirectories;
switch (fileType) {
case BrowseFileType::BOOTABLE:
[panel setAllowedFileTypes:[NSArray arrayWithObjects:@"iso", @"cso", @"chd", @"pbp", @"elf", @"zip", @"ppdmp", @"prx", @"cue", @"ccd", @"mds", @"mdf", @"bin", @"img", nil]];
break;
case BrowseFileType::SAVE_STATE:
[panel setAllowedFileTypes:[NSArray arrayWithObjects:@"ppst", nil]];
break;
case BrowseFileType::IMAGE:
[panel setAllowedFileTypes:[NSArray arrayWithObjects:@"jpg", @"png", nil]];
break;
case BrowseFileType::INI:
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"ini"]];
break;
case BrowseFileType::DB:
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"db"]];
break;
case BrowseFileType::SOUND_EFFECT:
[panel setAllowedFileTypes:[NSArray arrayWithObjects:@"wav", @"mp3", nil]];
break;
case BrowseFileType::SYMBOL_MAP:
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"ppsym"]];
break;
case BrowseFileType::SYMBOL_MAP_NOCASH:
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"sym"]];
break;
case BrowseFileType::ATRAC3:
[panel setAllowedFileTypes:[NSArray arrayWithObject:@"at3"]];
break;
default:
break;
}
// if (!allowFiles && allowDirectories)
// panel.allowedFileTypes = @[(__bridge NSString *)kUTTypeFolder];
DarwinPresentPanel(panel, ^(NSModalResponse modalResponse) {
if (modalResponse == NSModalResponseOK && panel.URLs.firstObject) {
INFO_LOG(Log::System, "Mac: Received OK response from panel");
panelCallback(true, Path(panel.URLs.firstObject.path.UTF8String));
} else if (modalResponse == NSModalResponseCancel) {
INFO_LOG(Log::System, "Mac: Received Cancel response from panel");
panelCallback(false, Path());
} else {
WARN_LOG(Log::System, "Mac: Received unknown response from panel");
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;
if (!rootViewController)
return;
// Open, not import - we don't want the files copied, we handle that ourselves if needed.
NSMutableArray<UTType *> *types = [NSMutableArray array];
if (allowDirectories)
[types addObject:UTTypeFolder];
if (allowFiles)
[types addObject:UTTypeItem];
UIDocumentPickerViewController *pickerVC = [[UIDocumentPickerViewController alloc] initForOpeningContentTypes:types];
// What if you wanted to go to heaven, but then God showed you the next few lines?
// serious note: have to do this, because __pickerDelegate has to stay retained as a class property
__pickerDelegate = (void *)CFBridgingRetain([[DocumentPickerDelegate alloc] initWithCallback:panelCallback]);
pickerVC.delegate = (__bridge DocumentPickerDelegate *)__pickerDelegate;
[rootViewController presentViewController:pickerVC animated:true completion:nil];
#endif
});
}
Path DarwinFileSystemServices::appropriateMemoryStickDirectoryToUse() {
NSString *userPreferred = [[NSUserDefaults standardUserDefaults] stringForKey:@(PreferredMemoryStickUserDefaultsKey)];
if (userPreferred)
return Path(userPreferred.UTF8String);
return defaultMemoryStickPath();
}
Path DarwinFileSystemServices::defaultMemoryStickPath() {
#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.
// In a folder of its own, since the system keeps things in Caches too.
NSString *cachesPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)
objectAtIndex:0];
NSString *memstickPath = [cachesPath stringByAppendingPathComponent:@"PPSSPP"];
[[NSFileManager defaultManager] createDirectoryAtPath:memstickPath withIntermediateDirectories:YES attributes:nil error:nil];
return Path(memstickPath.UTF8String);
#elif PPSSPP_PLATFORM(IOS)
NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)
objectAtIndex:0];
return Path(documentsPath.UTF8String);
#elif PPSSPP_PLATFORM(MAC)
return g_Config.defaultCurrentDirectory / ".config/ppsspp";
#endif
}
void DarwinFileSystemServices::setUserPreferredMemoryStickDirectory(Path path) {
[[NSUserDefaults standardUserDefaults] setObject:@(path.c_str())
forKey:@(PreferredMemoryStickUserDefaultsKey)];
g_Config.memStickDirectory = path;
}
void RestartMacApp() {
#if PPSSPP_PLATFORM(MAC)
NSURL *bundleURL = NSBundle.mainBundle.bundleURL;
NSTask *task = [[NSTask alloc] init];
task.executableURL = [NSURL fileURLWithPath:@"/usr/bin/open"];
task.arguments = @[@"-n", bundleURL.path];
[task launch];
exit(0);
#endif
}