1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
//! The render timer of the platform: a display link on a thread of its
//! own, which ticks once per frame of the display, up to 120 Hz on
//! ProMotion displays, and is paused while the render loop is idle or the
//! application is in the background.
use crate::interop::{kCFRunLoopCommonModes, CFRunLoopGetCurrent, CFRunLoopPerformBlock, CFRunLoopWakeUp};
use crate::platform::Platform;
use block2::RcBlock;
use ferroui_base::logging::{LogArea, LogEventLevel, Logger};
use ferroui_base::rendering::{IRenderTimer, RenderTimerTick};
use objc2::rc::{autoreleasepool, Retained};
use objc2::{define_class, msg_send, sel, AllocAnyThread, DefinedClass, MainThreadMarker};
use objc2_foundation::{
NSBundle, NSNotification, NSNotificationCenter, NSNotificationName, NSNumber, NSObject, NSObjectProtocol,
NSRunLoop, NSRunLoopCommonModes, NSString,
};
use objc2_quartz_core::{CADisplayLink, CAFrameRateRange};
use objc2_ui_kit::{
UIApplicationDidEnterBackgroundNotification, UIApplicationWillEnterForegroundNotification, UIDevice, UIScreen,
UIUserInterfaceIdiom,
};
use std::cell::RefCell;
use std::ffi::c_void;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::thread::Thread;
use std::time::Instant;
/// What the render loop, the main thread and the link thread share, under the lock of the timer.
struct LinkState {
tick: Option<RenderTimerTick>,
in_background: bool,
paused: bool,
/// The run loop of the timer thread (a `CFRunLoopRef`), once the link is on it.
run_loop: Option<usize>,
}
/// What the timer and the target of its display link share.
struct TimerState {
// Guards tick, in_background and paused, which the render loop, the main thread and the
// link thread share.
lock: Mutex<LinkState>,
/// The number of times the display link fired.
ticks: AtomicU64,
st: Instant,
}
impl TimerState {
fn lock(&self) -> MutexGuard<'_, LinkState> {
self.lock.lock().unwrap_or_else(PoisonError::into_inner)
}
fn on_link_tick(&self) {
self.ticks.fetch_add(1, Ordering::SeqCst);
// A callback already scheduled when the link was paused can still arrive.
let tick = {
let state = self.lock();
if state.paused {
None
} else {
state.tick.clone()
}
};
if let Some(tick) = tick {
tick(self.st.elapsed());
}
}
}
ferroui_base::ferro_thread_local! {
/// The display link, an object of the timer thread (set on that thread only).
static LINK: RefCell<Option<Retained<CADisplayLink>>> = const { RefCell::new(None) };
}
/// Called with the lock held. The reference sets `CADisplayLink.Paused` here, from whichever
/// thread changed the state; the link of the port is an object of the timer thread, so the
/// change is applied there (`apply_link_state`), through the run loop of that thread, which may
/// be given work from any thread.
fn update_link_state(this: &Arc<TimerState>, state: &mut LinkState) {
let paused = state.in_background || state.tick.is_none();
if state.paused != paused {
state.paused = paused;
if let Some(run_loop) = state.run_loop {
schedule_apply_link_state(this.clone(), run_loop);
}
}
}
fn schedule_apply_link_state(this: Arc<TimerState>, run_loop: usize) {
let block = RcBlock::new(move || apply_link_state(&this));
// SAFETY: the run loop is the one of the timer thread, which runs for the life of the
// process; `CFRunLoopRef` may be given work and woken up from any thread. The block takes no
// argument and returns nothing, as `CFRunLoopPerformBlock` calls it, and the function copies
// it, so the reference given back here is the caller's own. The mode is a constant of Core
// Foundation.
unsafe {
CFRunLoopPerformBlock(
run_loop as *mut c_void,
kCFRunLoopCommonModes,
&*block as *const block2::Block<dyn Fn()> as *const c_void,
);
CFRunLoopWakeUp(run_loop as *mut c_void);
}
}
/// On the timer thread: pauses or resumes the link as the state says.
fn apply_link_state(this: &TimerState) {
let paused = this.lock().paused;
LINK.with(|link| {
if let Some(link) = link.borrow().as_ref() {
link.setPaused(paused);
}
});
}
define_class!(
// SAFETY: `NSObject` has no requirements on a subclass, and the class
// does not implement `Drop`.
#[unsafe(super(NSObject))]
#[name = "FerroDisplayLinkTarget"]
#[ivars = Arc<TimerState>]
struct DisplayLinkTarget;
impl DisplayLinkTarget {
#[unsafe(method(onLinkTick:))]
fn on_link_tick(&self, _link: &CADisplayLink) {
// The run loop of a thread that is not the main thread drains
// no pool between its turns.
autoreleasepool(|_| self.ivars().on_link_tick());
}
}
unsafe impl NSObjectProtocol for DisplayLinkTarget {}
);
impl DisplayLinkTarget {
fn new(state: Arc<TimerState>) -> Retained<Self> {
let this = Self::alloc().set_ivars(state);
// SAFETY: `init` of the superclass, on the object that was just
// allocated and whose instance variables are set.
unsafe { msg_send![super(this), init] }
}
}
/// Render timer driven by a `CADisplayLink`, paused while the render loop is idle or the
/// application is in the background.
pub struct DisplayLinkTimer {
state: Arc<TimerState>,
timer_thread: Thread,
}
impl DisplayLinkTimer {
/// Creates the timer and starts its thread. The options of the platform
/// ([`Platform::options`]) are read here, so the platform has them first.
///
/// # Panics
/// Panics when the thread cannot be started.
pub fn new() -> Arc<DisplayLinkTimer> {
let state = Arc::new(TimerState {
lock: Mutex::new(LinkState { tick: None, in_background: false, paused: true, run_loop: None }),
ticks: AtomicU64::new(0),
st: Instant::now(),
});
let enable_high_refresh_rate = Self::enable_high_refresh_rate();
// The reference creates the link on the thread that registers the
// platform and adds it to the run loop of the timer thread. Here the
// link is an object of the timer thread from the start: it is
// created, scheduled, paused and resumed there, and no other thread
// touches it (DEVIATIONS.md, iOS backend).
let thread_state = state.clone();
let spawned = std::thread::Builder::new().name("DisplayLinkTimer".to_string()).spawn(move || {
let target = DisplayLinkTarget::new(thread_state.clone());
// SAFETY: the target is an object that responds to the selector
// (`onLinkTick:` above takes the link, the one argument a
// display link passes); the link retains its target. The mode
// is a constant of Foundation, and the run loop is the one of
// this thread.
unsafe {
let link = CADisplayLink::displayLinkWithTarget_selector(&target, sel!(onLinkTick:));
link.setPaused(true);
if enable_high_refresh_rate {
Self::set_preferred_frame_rate_range(&link);
}
let run_loop = NSRunLoop::currentRunLoop();
link.addToRunLoop_forMode(&run_loop, NSRunLoopCommonModes);
LINK.with(|slot| *slot.borrow_mut() = Some(link));
// From now on a change of the state is applied on this thread; the state of
// before is applied once here.
thread_state.lock().run_loop = Some(CFRunLoopGetCurrent() as usize);
apply_link_state(&thread_state);
run_loop.run();
}
});
let timer_thread = match spawned {
Ok(handle) => handle.thread().clone(),
Err(error) => panic!("Unable to start the thread of the render timer: {error}"),
};
let timer = Arc::new(DisplayLinkTimer { state, timer_thread });
if enable_high_refresh_rate {
timer.warn_if_limited_to_60_hz();
}
// SAFETY: the two names are constants of UIKit.
let (entered_background, will_enter_foreground) =
unsafe { (UIApplicationDidEnterBackgroundNotification, UIApplicationWillEnterForegroundNotification) };
Self::observe(entered_background, timer.state.clone(), true);
Self::observe(will_enter_foreground, timer.state.clone(), false);
timer
}
/// The thread of the display link: where the timer ticks and, the
/// timer running in the background, where the compositor renders.
pub fn timer_thread(&self) -> &Thread {
&self.timer_thread
}
/// `iOSPlatformOptions.EnableHighRefreshRate` of the options of the platform; false when the
/// platform has no options yet (`Platform.Options is { EnableHighRefreshRate: true }`).
fn enable_high_refresh_rate() -> bool {
Platform::options().is_some_and(|options| options.enable_high_refresh_rate)
}
/// Lets `link` follow the display refresh rate instead of the 60 Hz default; the caller
/// asks [`IosPlatformOptions::enable_high_refresh_rate`](crate::IosPlatformOptions) first.
/// On iPhone, this also needs CADisableMinimumFrameDurationOnPhone in the app's Info.plist.
/// The reference asks for iOS, tvOS or Mac Catalyst 15, the systems with
/// `preferredFrameRateRange`; the port asks the link whether it has it.
fn set_preferred_frame_rate_range(link: &CADisplayLink) {
if link.respondsToSelector(sel!(setPreferredFrameRateRange:)) {
link.setPreferredFrameRateRange(CAFrameRateRange { minimum: 60.0, maximum: 120.0, preferred: 120.0 });
}
}
/// Warns when the display can go above 60 Hz and the application's Info.plist does not
/// allow it. Only on the main thread, where UIKit may be asked.
fn warn_if_limited_to_60_hz(&self) {
let Some(mtm) = MainThreadMarker::new() else {
return;
};
if UIDevice::currentDevice(mtm).userInterfaceIdiom() != UIUserInterfaceIdiom::Phone {
return;
}
if UIScreen::mainScreen(mtm).maximumFramesPerSecond() <= 60 {
return;
}
let key = NSString::from_str("CADisableMinimumFrameDurationOnPhone");
let allowed = NSBundle::mainBundle()
.objectForInfoDictionaryKey(&key)
.and_then(|value| value.downcast::<NSNumber>().ok())
.is_some_and(|value| value.boolValue());
if !allowed {
if let Some(logger) = Logger::try_get(LogEventLevel::Warning, LogArea::IOS_PLATFORM) {
logger.log(
Some(self as &dyn std::any::Any),
"Rendering is limited to 60 Hz on this ProMotion display. Set CADisableMinimumFrameDurationOnPhone \
to true in Info.plist, or set IosPlatformOptions::enable_high_refresh_rate to false.",
);
}
}
}
fn set_in_background(state: &Arc<TimerState>, in_background: bool) {
let mut link_state = state.lock();
link_state.in_background = in_background;
update_link_state(state, &mut link_state);
}
fn observe(name: &NSNotificationName, state: Arc<TimerState>, in_background: bool) {
let block = RcBlock::new(move |_notification: NonNull<NSNotification>| {
Self::set_in_background(&state, in_background);
});
// SAFETY: the block takes the one argument of a notification block
// and ignores it, and what it captures is shared state that any
// thread may change (under the lock of the timer), so the block may
// be called on whichever thread posts the notification.
let observer = unsafe {
NSNotificationCenter::defaultCenter().addObserverForName_object_queue_usingBlock(
Some(name),
None,
None,
&block,
)
};
// The timer lives as long as the process, and so does the
// subscription.
std::mem::forget(observer);
}
}
impl DisplayLinkTimer {
/// The number of times the display link fired, whether or not a frame
/// was asked for. An addition of the port, for diagnostics.
pub fn ticks(&self) -> u64 {
self.state.ticks.load(Ordering::SeqCst)
}
/// Whether a tick handler is set: whether the render loop runs.
pub fn has_tick(&self) -> bool {
self.state.lock().tick.is_some()
}
}
impl IRenderTimer for DisplayLinkTimer {
fn tick(&self) -> Option<RenderTimerTick> {
self.state.lock().tick.clone()
}
fn set_tick(&self, value: Option<RenderTimerTick>) {
let mut link_state = self.state.lock();
link_state.tick = value;
update_link_state(&self.state, &mut link_state);
}
fn runs_in_background(&self) -> bool {
true
}
}