Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions dm/ios/Sources/DmSceneRouting.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// iOS-only scene ownership. Events keep the existing front end's 0..3 values;
// connect/disconnect additionally maintain the opt-in shared-runtime registry.
#pragma once
#import <UIKit/UIKit.h>

int front_ios_external_enabled(void);
UIViewController *front_ios_external_root(UIWindowScene *scene);
void front_ios_external_disconnect(UIViewController *controller);
void front_ios_scene_event(UIWindowScene *scene, int event);
void front_ios_scene_changed(UIWindowScene *scene);
144 changes: 137 additions & 7 deletions dm/ios/Sources/front_ios.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "hle.h"
#include "dm.h"
#include "haptics_apple.h"
#import "DmSceneRouting.h"

UIView *dm_gamepad_view(CGRect frame);
void dm_gamepad_reset(void);
Expand All @@ -19,6 +20,22 @@
bool dm_show_launcher(void *view_controller, void (*done)(void)); // Launcher.swift: false = no launcher (no dm/launcher.json, or DM_LAUNCHER=0)

static CAMetalLayer *g_layer;
static NSMapTable<UIWindowScene *, NSNumber *> *g_scene_focus;
static NSHashTable<UIViewController *> *g_external_roots;
static __weak UIViewController *g_external_output;
static int g_route_focus = 3;
static BOOL g_route_runtime_started, g_route_suspended;
static void external_refresh(void);
static void external_register(UIViewController *controller);
void front_ios_focus_event(int event);
static void front_ios_active_recover(void);
void mtl_link_pause(int on);
int front_ios_external_enabled(void) { return [[NSBundle.mainBundle objectForInfoDictionaryKey:@"DMExternalDisplayEnabled"] boolValue]; }
static CGRect fit_game(CGRect bounds) {
CGFloat w = bounds.size.width, h = bounds.size.height;
if (w / h > 16.0 / 9.0) w = h * 16.0 / 9.0; else h = w * 9.0 / 16.0;
return CGRectMake(floor((bounds.size.width - w) / 2), floor((bounds.size.height - h) / 2), w, h);
}
static void front_ios_power_watch(void);
static void front_ios_audio_watch(void);
static UILabel *front_ios_fps_label(void);
Expand Down Expand Up @@ -94,6 +111,8 @@ @interface DmRootVC : UIViewController
@property (nonatomic, strong) UIView *pad;
@property (nonatomic, strong) UILabel *fps;
@property (nonatomic) BOOL launched;
@property (nonatomic, strong) id externalAccessory;
@property (nonatomic, strong) UILabel *externalNote;
- (void)startGame;
@end
static DmRootVC *g_root;
Expand Down Expand Up @@ -129,14 +148,16 @@ - (void)startGame {
if (self.fps) [self.view addSubview:self.fps];
[self.view setNeedsLayout]; [self.view layoutIfNeeded];
UIApplication.sharedApplication.idleTimerDisabled = YES;
if (front_ios_external_enabled()) { external_register(self); external_refresh(); }
front_ios_boot();
g_route_runtime_started = YES;
if (front_ios_external_enabled()) external_refresh();
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
CGRect b = self.view.bounds; if (!self.game) return;
CGFloat w = b.size.width, h = b.size.height; // Fit 16:9 into the whole screen
if (w / h > 16.0 / 9.0) w = h * 16.0 / 9.0; else h = w * 9.0 / 16.0;
self.game.frame = CGRectMake(floor((b.size.width - w) / 2), floor((b.size.height - h) / 2), w, h);
if (self.game.superview == self.view) self.game.frame = fit_game(b);
self.externalNote.frame = b;
if (self.fps) { // the top row's free stretch: right of DmGamepad.m's L3 key (102 + 17 of its units from the safe edge), level with − / + (22 units down); u is its unit
CGRect s = UIEdgeInsetsInsetRect(b, self.view.safeAreaInsets); CGFloat u = MIN(b.size.height / 430.0, 1.3);
self.fps.frame = CGRectMake(floor(CGRectGetMinX(s) + 130 * u), floor(CGRectGetMinY(s) + 22 * u - 11), 64, 22);
Expand All @@ -158,7 +179,113 @@ - (UIRectEdge)preferredScreenEdgesDeferringSystemGestures { return UIRectEdgeAll
- (BOOL)prefersStatusBarHidden { return YES; }
- (UIInterfaceOrientationMask)supportedInterfaceOrientations { return UIInterfaceOrientationMaskLandscape; }
@end
UIViewController *front_ios_root(void) { return [DmRootVC new]; }
UIViewController *front_ios_root(void) {
if (!front_ios_external_enabled()) return [DmRootVC new];
// Reconnecting the primary scene reuses its controls, Metal layer and guest.
if (!g_root) g_root = [DmRootVC new];
return g_root;
}

@interface DmExternalRootVC : UIViewController
@property (nonatomic, weak) UIWindowScene *scene;
@property (nonatomic, strong) UILabel *hint;
@end
@implementation DmExternalRootVC
- (void)viewDidLoad {
[super viewDidLoad]; self.view.backgroundColor = UIColor.blackColor;
self.hint = [[UILabel alloc] initWithFrame:self.view.bounds];
self.hint.text = @"Start the game on your iPhone"; self.hint.textAlignment = NSTextAlignmentCenter;
self.hint.textColor = UIColor.whiteColor; self.hint.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.hint];
}
- (void)viewDidLayoutSubviews {
[super viewDidLayoutSubviews];
if (g_root.game.superview == self.view) g_root.game.frame = fit_game(self.view.bounds);
self.hint.hidden = g_root.game.superview == self.view;
}
- (BOOL)prefersStatusBarHidden { return YES; }
@end

static int scene_focus(UIWindowScene *scene) { return scene ? ([g_scene_focus objectForKey:scene] ?: @3).intValue : 3; }
static void external_register(UIViewController *controller) {
if (@available(iOS 27.0, *)) {
DmRootVC *root = (DmRootVC *)controller; if (root.externalAccessory) return;
UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:@"External game display" sessionRole:UIWindowSceneSessionRoleExternalDisplayNonInteractive];
configuration.delegateClass = NSClassFromString(@"DmExternalSceneDelegate");
root.externalAccessory = [root registerSceneAccessory:[UISceneAccessory externalNonInteractiveSceneAccessoryWithConfiguration:configuration]];
}
}
UIViewController *front_ios_external_root(UIWindowScene *scene) {
DmExternalRootVC *root = [DmExternalRootVC new]; root.scene = scene;
if (!g_external_roots) g_external_roots = [NSHashTable weakObjectsHashTable];
[g_external_roots addObject:root];
return root;
}
void front_ios_external_disconnect(UIViewController *controller) {
[g_external_roots removeObject:controller];
if (g_external_output == controller) g_external_output = nil;
external_refresh();
}
static void external_refresh(void) {
if (!front_ios_external_enabled() || !g_root.game) return;
int desired = scene_focus(g_root.view.window.windowScene);
DmExternalRootVC *target = (DmExternalRootVC *)g_external_output;
if (target && (![g_external_roots containsObject:target] || scene_focus(target.scene) == 3)) target = nil;
for (DmExternalRootVC *root in g_external_roots) {
int focus = scene_focus(root.scene); if (focus < desired) desired = focus;
if (!target && focus != 3) target = root;
}
UIView *host = target ? target.view : g_root.view;
BOOL moving = g_root.game.superview != host;
// The phone retains the pad and FPS label. The very same Metal view/layer
// changes parent, so the renderer never observes a replacement layer or a
// second guest. Stop submissions and drain their output before that move.
if (desired == 3 && g_route_runtime_started && !g_route_suspended) {
front_ios_focus_event(1); g_route_suspended = YES;
}
BOOL fenced = moving && g_route_runtime_started && !g_route_suspended;
if (fenced) dm_gfx_suspend(1);
if (moving && g_route_runtime_started) mtl_link_pause(1);
if (moving) {
[g_root.game removeFromSuperview]; [host insertSubview:g_root.game atIndex:0];
[host setNeedsLayout]; [host layoutIfNeeded];
if (target) [target viewDidLayoutSubviews]; else [g_root viewDidLayoutSubviews];
[g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded];
LOG("external display: render layer moved to %s; phone controls retained", target ? "external scene" : "primary scene");
}
g_external_output = target;
if (target && !g_root.externalNote) {
g_root.externalNote = [[UILabel alloc] initWithFrame:g_root.view.bounds];
g_root.externalNote.text = @"Playing on your external display"; g_root.externalNote.textAlignment = NSTextAlignmentCenter;
g_root.externalNote.textColor = UIColor.secondaryLabelColor;
g_root.externalNote.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[g_root.view insertSubview:g_root.externalNote atIndex:0];
}
g_root.externalNote.hidden = !target;
if (desired != 3) {
if (g_route_runtime_started && (moving || g_route_suspended)) mtl_link_pause(0);
if (g_route_suspended || g_route_focus == 3) { front_ios_focus_event(2); g_route_suspended = NO; }
else if (fenced) dm_gfx_suspend(0);
if (desired != g_route_focus) front_ios_focus_event(desired == 1 ? 3 : 0);
Comment thread
oliverames marked this conversation as resolved.
Resolved
}
g_route_focus = desired;
}
void front_ios_scene_event(UIWindowScene *scene, int event) {
if (!front_ios_external_enabled()) { if (event < 4) front_ios_focus_event(event); return; }
int already_active = g_route_focus == 1;
if (!g_scene_focus) g_scene_focus = [NSMapTable weakToStrongObjectsMapTable];
if (event == 5) [g_scene_focus removeObjectForKey:scene];
else {
int focus = event == 1 ? 3 : event == 3 ? 1 : 2;
if (event == 4) focus = scene.activationState == UISceneActivationStateForegroundActive ? 1 : scene.activationState == UISceneActivationStateForegroundInactive ? 2 : 3;
[g_scene_focus setObject:@(focus) forKey:scene];
}
external_refresh();
// An active external scene can keep aggregate focus unchanged while the
// phone returns. Preserve audio/controller recovery without resetting guest
// focus or its activation time. A focus transition already recovered below.
if (event == 3 && already_active && scene == g_root.view.window.windowScene) front_ios_active_recover();
}
void front_ios_scene_changed(UIWindowScene *scene) {
if (g_root.game.window.windowScene != scene) return;
[g_root.game setNeedsLayout]; [g_root.game layoutIfNeeded];
Expand Down Expand Up @@ -311,6 +438,11 @@ static void front_ios_audio_watch(void) {

// Scene lifecycle (called by SceneDelegate in main.m): 0 resign active, 1 enter background, 2 return to foreground, 3 become active. On entering background, freeze the guest clock and suspend after in-flight GPU work completes (submitting GPU work in background gets the process killed).
// The screen stays on while the game is active: only touches reset the idle timer, so with a controller (or during a cutscene) the screen dimmed and locked mid-game
static void front_ios_active_recover(void) {
if (g_audio_intr) { LOG("audio session: active again with no interruption-ended notice; resuming"); g_audio_intr = 0; g_session_off = 1; }
if (g_session_off) front_ios_audio_session();
front_ios_audio_apply(); dm_gamepad_active();
}
void front_ios_focus_event(int ev) {
if (!g_layer) return;
switch (ev) {
Expand All @@ -319,8 +451,6 @@ void front_ios_focus_event(int ev) {
case 2: dm_gfx_suspend(0); guest_clock_freeze_for(1, 0); front_ios_audio_session(); g_audio_bg = 0; front_ios_audio_apply(); dm_focus_set(2); break;
case 3: // Becoming active again also ends an interruption whose "ended" never came (not every one sends it), and retries a session that would not activate on the way back
UIApplication.sharedApplication.idleTimerDisabled = YES;
if (g_audio_intr) { LOG("audio session: active again with no interruption-ended notice; resuming"); g_audio_intr = 0; g_session_off = 1; }
if (g_session_off) front_ios_audio_session();
front_ios_audio_apply(); dm_gamepad_active(); dm_focus_set(1); break;
front_ios_active_recover(); dm_focus_set(1); break;
}
}
39 changes: 34 additions & 5 deletions dm/ios/Sources/main.m
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "romfs_image.h"
#include "romfs_identity.h"
#include "player_env.h"
#import "DmSceneRouting.h"
#ifndef DM_PLAYER_BUILD
#define DM_PLAYER_BUILD 0
#endif
Expand Down Expand Up @@ -168,6 +169,10 @@ @interface App : UIResponder <UIApplicationDelegate>
@end
@implementation App
- (UISceneConfiguration *)application:(UIApplication *)a configurationForConnectingSceneSession:(UISceneSession *)s options:(UISceneConnectionOptions *)o {
if (front_ios_external_enabled() && [s.role isEqualToString:UIWindowSceneSessionRoleExternalDisplayNonInteractive]) {
UISceneConfiguration *configuration = [[UISceneConfiguration alloc] initWithName:@"External game display" sessionRole:s.role];
configuration.delegateClass = NSClassFromString(@"DmExternalSceneDelegate"); return configuration;
}
return [[UISceneConfiguration alloc] initWithName:@"Default" sessionRole:s.role];
}
@end
Expand All @@ -180,15 +185,39 @@ @implementation SceneDelegate
- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)o {
self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
self.window.rootViewController = front_ios_root();
[self.window makeKeyAndVisible];
[self.window makeKeyAndVisible]; front_ios_scene_event((UIWindowScene *)scene, 4);
}
- (void)sceneWillResignActive:(UIScene *)s { front_ios_focus_event(0); }
- (void)sceneDidDisconnect:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 5); }
- (void)sceneWillResignActive:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 0); }
- (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previous {
(void)previous; front_ios_scene_changed(scene);
}
- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_focus_event(1); }
- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_focus_event(2); }
- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_focus_event(3); }
- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 1); }
- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 2); }
- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_scene_event((UIWindowScene *)s, 3); }
@end

// Only this content scene is added for opted-in builds. It never starts the
// host and never becomes key: text input and game controls stay on the phone.
@interface DmExternalSceneDelegate : UIResponder <UIWindowSceneDelegate>
@property (strong, nonatomic) UIWindow *window;
@end
@implementation DmExternalSceneDelegate
- (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)options {
(void)session; (void)options;
self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
self.window.rootViewController = front_ios_external_root((UIWindowScene *)scene);
self.window.hidden = NO; front_ios_scene_event((UIWindowScene *)scene, 4);
}
- (void)sceneWillResignActive:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 0); }
- (void)sceneDidEnterBackground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 1); }
- (void)sceneWillEnterForeground:(UIScene *)s { front_ios_scene_event((UIWindowScene *)s, 2); }
- (void)sceneDidBecomeActive:(UIScene *)s { front_ios_scene_changed((UIWindowScene *)s); front_ios_scene_event((UIWindowScene *)s, 3); }
- (void)windowScene:(UIWindowScene *)scene didUpdateEffectiveGeometry:(UIWindowSceneGeometry *)previous { (void)previous; front_ios_scene_changed(scene); }
- (void)sceneDidDisconnect:(UIScene *)s {
front_ios_external_disconnect(self.window.rootViewController);
front_ios_scene_event((UIWindowScene *)s, 5); self.window = nil;
}
@end

int main(int argc, char **argv) {
Expand Down
Loading