From b46e26e65aac8f7d5b5c83d43ea99a507c093930 Mon Sep 17 00:00:00 2001
From: Sam Lantinga <[EMAIL REDACTED]>
Date: Thu, 30 Oct 2025 18:19:58 -0700
Subject: [PATCH] Added support for the UIScene life cycle on Apple platforms
Fixes https://github.com/libsdl-org/SDL/issues/12680
---
include/SDL3/SDL_video.h | 2 +
src/video/uikit/SDL_uikitappdelegate.h | 9 ++
src/video/uikit/SDL_uikitappdelegate.m | 178 ++++++++++++++++++++++++-
src/video/uikit/SDL_uikitwindow.m | 53 +++++++-
4 files changed, 235 insertions(+), 7 deletions(-)
diff --git a/include/SDL3/SDL_video.h b/include/SDL3/SDL_video.h
index d727457191aab..7e58bc12af99d 100644
--- a/include/SDL3/SDL_video.h
+++ b/include/SDL3/SDL_video.h
@@ -97,6 +97,8 @@ typedef Uint32 SDL_WindowID;
* uninitialized will either return the user provided value, if one was set
* prior to initialization, or NULL. See docs/README-wayland.md for more
* information.
+ *
+ * \since This macro is available since SDL 3.2.0.
*/
#define SDL_PROP_GLOBAL_VIDEO_WAYLAND_WL_DISPLAY_POINTER "SDL.video.wayland.wl_display"
diff --git a/src/video/uikit/SDL_uikitappdelegate.h b/src/video/uikit/SDL_uikitappdelegate.h
index 77ccbfdd2ca93..25dfc9d3c928f 100644
--- a/src/video/uikit/SDL_uikitappdelegate.h
+++ b/src/video/uikit/SDL_uikitappdelegate.h
@@ -29,6 +29,15 @@
@end
+API_AVAILABLE(ios(13.0))
+@interface SDLUIKitSceneDelegate : NSObject <UIApplicationDelegate, UIWindowSceneDelegate>
+
++ (NSString *)getSceneDelegateClassName;
+
+- (void)hideLaunchScreen;
+
+@end
+
@interface SDLUIKitDelegate : NSObject <UIApplicationDelegate>
+ (id)sharedAppDelegate;
diff --git a/src/video/uikit/SDL_uikitappdelegate.m b/src/video/uikit/SDL_uikitappdelegate.m
index 3c1bb37366f4b..2af7165890cef 100644
--- a/src/video/uikit/SDL_uikitappdelegate.m
+++ b/src/video/uikit/SDL_uikitappdelegate.m
@@ -59,7 +59,15 @@ int SDL_RunApp(int argc, char *argv[], SDL_main_func mainFunction, void *reserve
// Give over control to run loop, SDLUIKitDelegate will handle most things from here
@autoreleasepool {
- UIApplicationMain(argc, argv, nil, [SDLUIKitDelegate getAppDelegateClassName]);
+ NSString *name = nil;
+
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ name = [SDLUIKitSceneDelegate getSceneDelegateClassName];
+ }
+ if (!name) {
+ name = [SDLUIKitDelegate getAppDelegateClassName];
+ }
+ UIApplicationMain(argc, argv, nil, name);
}
// free the memory we used to hold copies of argc and argv
@@ -162,6 +170,7 @@ - (UIStatusBarStyle)preferredStatusBarStyle
@end
#endif // !SDL_PLATFORM_TVOS
+
@interface SDLLaunchScreenController ()
#ifndef SDL_PLATFORM_TVOS
@@ -343,7 +352,170 @@ - (NSUInteger)supportedInterfaceOrientations
}
#endif // !SDL_PLATFORM_TVOS
-@end
+@end // SDLLaunchScreenController
+
+
+API_AVAILABLE(ios(13.0))
+@implementation SDLUIKitSceneDelegate
+{
+ UIWindow *launchWindow;
+}
+
++ (NSString *)getSceneDelegateClassName
+{
+ return @"SDLUIKitSceneDelegate";
+}
+
+- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions
+{
+ if (![scene isKindOfClass:[UIWindowScene class]]) {
+ return;
+ }
+
+ UIWindowScene *windowScene = (UIWindowScene *)scene;
+ windowScene.delegate = self;
+
+ NSBundle *bundle = [NSBundle mainBundle];
+
+#ifdef SDL_IPHONE_LAUNCHSCREEN
+ UIViewController *vc = nil;
+ NSString *screenname = nil;
+
+#if !defined(SDL_PLATFORM_TVOS) && !defined(SDL_PLATFORM_VISIONOS)
+ screenname = [bundle objectForInfoDictionaryKey:@"UILaunchStoryboardName"];
+
+ if (screenname) {
+ @try {
+ UIStoryboard *storyboard = [UIStoryboard storyboardWithName:screenname bundle:bundle];
+ __auto_type storyboardVc = [storyboard instantiateInitialViewController];
+ vc = [[SDLLaunchStoryboardViewController alloc] initWithStoryboardViewController:storyboardVc];
+ }
+ @catch (NSException *exception) {
+ // Do nothing (there's more code to execute below).
+ }
+ }
+#endif
+
+ if (vc == nil) {
+ vc = [[SDLLaunchScreenController alloc] initWithNibName:screenname bundle:bundle];
+ }
+
+ if (vc.view) {
+#ifdef SDL_PLATFORM_VISIONOS
+ CGRect viewFrame = CGRectMake(0, 0, SDL_XR_SCREENWIDTH, SDL_XR_SCREENHEIGHT);
+#else
+ CGRect viewFrame = windowScene.coordinateSpace.bounds;
+#endif
+ launchWindow = [[UIWindow alloc] initWithWindowScene:windowScene];
+ launchWindow.frame = viewFrame;
+
+ launchWindow.windowLevel = UIWindowLevelNormal + 1.0;
+ launchWindow.hidden = NO;
+ launchWindow.rootViewController = vc;
+ }
+#endif
+
+ // Set working directory to resource path
+ [[NSFileManager defaultManager] changeCurrentDirectoryPath:[bundle resourcePath]];
+
+ // Handle any connection options (like opening URLs)
+ for (NSUserActivity *activity in connectionOptions.userActivities) {
+ if (activity.webpageURL) {
+ [self handleURL:activity.webpageURL];
+ }
+ }
+
+ for (UIOpenURLContext *urlContext in connectionOptions.URLContexts) {
+ [self handleURL:urlContext.URL];
+ }
+
+ SDL_SetMainReady();
+ [self performSelector:@selector(postFinishLaunch) withObject:nil afterDelay:0.0];
+}
+
+- (void)scene:(UIScene *)scene openURLContexts:(NSSet<UIOpenURLContext *> *)URLContexts
+{
+ for (UIOpenURLContext *context in URLContexts) {
+ [self handleURL:context.URL];
+ }
+}
+
+- (void)sceneDidBecomeActive:(UIScene *)scene
+{
+ SDL_OnApplicationDidEnterForeground();
+}
+
+- (void)sceneWillResignActive:(UIScene *)scene
+{
+ SDL_OnApplicationWillEnterBackground();
+}
+
+- (void)sceneWillEnterForeground:(UIScene *)scene
+{
+ SDL_OnApplicationWillEnterForeground();
+}
+
+- (void)sceneDidEnterBackground:(UIScene *)scene
+{
+ SDL_OnApplicationDidEnterBackground();
+}
+
+- (void)handleURL:(NSURL *)url
+{
+ const char *sourceApplicationCString = NULL;
+ NSURL *fileURL = url.filePathURL;
+ if (fileURL != nil) {
+ SDL_SendDropFile(NULL, sourceApplicationCString, fileURL.path.UTF8String);
+ } else {
+ SDL_SendDropFile(NULL, sourceApplicationCString, url.absoluteString.UTF8String);
+ }
+ SDL_SendDropComplete(NULL);
+}
+
+- (void)hideLaunchScreen
+{
+ UIWindow *window = launchWindow;
+
+ if (!window || window.hidden) {
+ return;
+ }
+
+ launchWindow = nil;
+
+ [UIView animateWithDuration:0.2
+ animations:^{
+ window.alpha = 0.0;
+ }
+ completion:^(BOOL finished) {
+ window.hidden = YES;
+ UIKit_ForceUpdateHomeIndicator();
+ }];
+}
+
+- (void)postFinishLaunch
+{
+ [self performSelector:@selector(hideLaunchScreen) withObject:nil afterDelay:0.0];
+
+ SDL_SetiOSEventPump(true);
+ exit_status = forward_main(forward_argc, forward_argv);
+ SDL_SetiOSEventPump(false);
+
+ if (launchWindow) {
+ launchWindow.hidden = YES;
+ launchWindow = nil;
+ }
+}
+
+- (UISceneConfiguration *)application:(UIApplication *)application configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession options:(UISceneConnectionOptions *)options API_AVAILABLE(ios(13.0))
+{
+ // This doesn't appear to be called, but it needs to be implemented to signal that we support the UIScene life cycle
+ UISceneConfiguration *config = [[UISceneConfiguration alloc] initWithName:@"SDLSceneConfiguration" sessionRole:connectingSceneSession.role];
+ config.delegateClass = [SDLUIKitSceneDelegate class];
+ return config;
+}
+
+@end // SDLUIKitSceneDelegate
+
@implementation SDLUIKitDelegate
{
@@ -514,6 +686,6 @@ - (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDiction
return YES;
}
-@end
+@end // SDLUIKitDelegate
#endif // SDL_VIDEO_DRIVER_UIKIT
diff --git a/src/video/uikit/SDL_uikitwindow.m b/src/video/uikit/SDL_uikitwindow.m
index 2b258b19139b5..5fa0a3173c5ed 100644
--- a/src/video/uikit/SDL_uikitwindow.m
+++ b/src/video/uikit/SDL_uikitwindow.m
@@ -152,6 +152,43 @@ static bool SetupWindowData(SDL_VideoDevice *_this, SDL_Window *window, UIWindow
return true;
}
+API_AVAILABLE(ios(13.0))
+static UIWindowScene *GetActiveWindowScene(void)
+{
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ NSSet<UIScene *> *connectedScenes = [UIApplication sharedApplication].connectedScenes;
+
+ // First, try to find an active foreground scene
+ for (UIScene *scene in connectedScenes) {
+ if ([scene isKindOfClass:[UIWindowScene class]]) {
+ UIWindowScene *windowScene = (UIWindowScene *)scene;
+ if (windowScene.activationState == UISceneActivationStateForegroundActive) {
+ return windowScene;
+ }
+ }
+ }
+
+ // If no active scene, return any foreground scene
+ for (UIScene *scene in connectedScenes) {
+ if ([scene isKindOfClass:[UIWindowScene class]]) {
+ UIWindowScene *windowScene = (UIWindowScene *)scene;
+ if (windowScene.activationState == UISceneActivationStateForegroundInactive) {
+ return windowScene;
+ }
+ }
+ }
+
+ // Last resort: return first window scene
+ for (UIScene *scene in connectedScenes) {
+ if ([scene isKindOfClass:[UIWindowScene class]]) {
+ return (UIWindowScene *)scene;
+ }
+ }
+ }
+
+ return nil;
+}
+
bool UIKit_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_PropertiesID create_props)
{
@autoreleasepool {
@@ -197,13 +234,21 @@ bool UIKit_CreateWindow(SDL_VideoDevice *_this, SDL_Window *window, SDL_Properti
}
#endif // !SDL_PLATFORM_TVOS
- // ignore the size user requested, and make a fullscreen window
- // !!! FIXME: can we have a smaller view?
+ UIWindow *uiwindow = nil;
+ if (@available(iOS 13.0, tvOS 13.0, *)) {
+ UIWindowScene *scene = GetActiveWindowScene();
+ if (scene) {
+ uiwindow = [[SDL_uikitwindow alloc] initWithWindowScene:scene];
+ }
+ }
+ if (!uiwindow) {
+ // ignore the size user requested, and make a fullscreen window
#ifdef SDL_PLATFORM_VISIONOS
- UIWindow *uiwindow = [[SDL_uikitwindow alloc] initWithFrame:CGRectMake(window->x, window->y, window->w, window->h)];
+ uiwindow = [[SDL_uikitwindow alloc] initWithFrame:CGRectMake(0, 0, SDL_XR_SCREENWIDTH, SDL_XR_SCREENHEIGHT)];
#else
- UIWindow *uiwindow = [[SDL_uikitwindow alloc] initWithFrame:data.uiscreen.bounds];
+ uiwindow = [[SDL_uikitwindow alloc] initWithFrame:data.uiscreen.bounds];
#endif
+ }
// put the window on an external display if appropriate.
#ifndef SDL_PLATFORM_VISIONOS