Skip to content

Commit d3d28ed

Browse files
huntiefacebook-github-bot
authored andcommitted
Support light/dark mode toggling via Emulation.setEmulatedMedia
Summary: Implements the `Emulation.setEmulatedMedia` CDP method in `jsinspector-modern`, scoped to `prefers-color-scheme` emulation. This allows CDP clients to toggle the app color scheme for debugging without changing system settings. **Implementation notes** Adds a new `EmulationAgent` CDP domain agent that validates the `features` param, rejects unsupported media features/types, and delegates to `HostTargetDelegate::onSetEmulatedMedia`. Platform delegates: - **Android**: Calls `AppCompatDelegate.setDefaultNightMode()` on the UI thread via JNI through `ReactHostImpl`. - **iOS**: Sets `overrideUserInterfaceStyle` on the key `UIWindow`. Both platforms trigger their existing `Appearance` change event propagation to JS automatically. Changelog: [General][Added] - **React Native DevTools**: Add support for light/dark mode emulation via `Emulation.setEmulatedMedia` Differential Revision: D101624433
1 parent 46c0177 commit d3d28ed

8 files changed

Lines changed: 245 additions & 1 deletion

File tree

packages/react-native/ReactAndroid/src/main/java/com/facebook/react/runtime/ReactHostImpl.kt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import android.content.Context
1212
import android.content.Intent
1313
import android.nfc.NfcAdapter
1414
import android.os.Bundle
15+
import androidx.appcompat.app.AppCompatDelegate
1516
import androidx.core.graphics.createBitmap
1617
import com.facebook.common.logging.FLog
1718
import com.facebook.infer.annotation.Assertions
@@ -448,6 +449,19 @@ public class ReactHostImpl(
448449
InspectorNetworkHelper.loadNetworkResource(url, listener)
449450
}
450451

452+
@DoNotStrip
453+
private fun setEmulatedMedia(colorScheme: String) {
454+
UiThreadUtil.runOnUiThread {
455+
val mode =
456+
when (colorScheme) {
457+
"dark" -> AppCompatDelegate.MODE_NIGHT_YES
458+
"light" -> AppCompatDelegate.MODE_NIGHT_NO
459+
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
460+
}
461+
AppCompatDelegate.setDefaultNightMode(mode)
462+
}
463+
}
464+
451465
@DoNotStrip
452466
private fun captureScreenshot(format: String, quality: Int): String? {
453467
val activity = currentActivity ?: return null

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,16 @@ std::optional<std::string> JReactHostInspectorTarget::captureScreenshot(
159159
return std::nullopt;
160160
}
161161

162+
bool JReactHostInspectorTarget::onSetEmulatedMedia(
163+
const jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest&
164+
request) {
165+
if (auto javaReactHostImplStrong = javaReactHostImpl_->get()) {
166+
javaReactHostImplStrong->setEmulatedMedia(request.colorScheme);
167+
return true;
168+
}
169+
return false;
170+
}
171+
162172
HostTarget* JReactHostInspectorTarget::getInspectorTarget() {
163173
return inspectorTarget_ ? inspectorTarget_.get() : nullptr;
164174
}

packages/react-native/ReactAndroid/src/main/jni/react/runtime/jni/JReactHostInspectorTarget.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,12 @@ struct JReactHostImpl : public jni::JavaClass<JReactHostImpl> {
152152
"captureScreenshot");
153153
return method(self(), jni::make_jstring(format), static_cast<jint>(quality));
154154
}
155+
156+
void setEmulatedMedia(const std::string &colorScheme)
157+
{
158+
static auto method = javaClassStatic()->getMethod<void(jni::local_ref<jni::JString>)>("setEmulatedMedia");
159+
method(self(), jni::make_jstring(colorScheme));
160+
}
155161
};
156162

157163
/**
@@ -284,6 +290,7 @@ class JReactHostInspectorTarget : public jni::HybridClass<JReactHostInspectorTar
284290
jsinspector_modern::ScopedExecutor<jsinspector_modern::NetworkRequestListener> executor) override;
285291
std::optional<std::string> captureScreenshot(
286292
const jsinspector_modern::HostTargetDelegate::PageCaptureScreenshotRequest &request) override;
293+
bool onSetEmulatedMedia(const jsinspector_modern::HostTargetDelegate::SetEmulatedMediaRequest &request) override;
287294
jsinspector_modern::HostTargetTracingDelegate *getTracingDelegate() override;
288295

289296
private:
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#include "EmulationAgent.h"
9+
10+
#include <jsinspector-modern/cdp/CdpJson.h>
11+
12+
namespace facebook::react::jsinspector_modern {
13+
14+
EmulationAgent::EmulationAgent(
15+
FrontendChannel frontendChannel,
16+
HostTargetController& hostTargetController)
17+
: frontendChannel_(std::move(frontendChannel)),
18+
hostTargetController_(hostTargetController) {}
19+
20+
bool EmulationAgent::handleRequest(const cdp::PreparsedRequest& req) {
21+
if (req.method != "Emulation.setEmulatedMedia") {
22+
return false;
23+
}
24+
25+
if (req.params.isObject() && req.params.count("media") != 0u &&
26+
!req.params.at("media").empty()) {
27+
frontendChannel_(
28+
cdp::jsonError(
29+
req.id,
30+
cdp::ErrorCode::MethodNotFound,
31+
"Emulation.setEmulatedMedia: media type emulation is not supported"));
32+
return true;
33+
}
34+
35+
if (!req.params.isObject() || req.params.count("features") == 0u ||
36+
!req.params.at("features").isArray()) {
37+
frontendChannel_(cdp::jsonResult(req.id));
38+
return true;
39+
}
40+
41+
const auto& features = req.params.at("features");
42+
43+
std::string colorSchemeValue;
44+
bool hasColorScheme = false;
45+
46+
for (const auto& feature : features) {
47+
if (!feature.isObject() || feature.count("name") == 0u) {
48+
continue;
49+
}
50+
51+
const auto& name = feature.at("name").asString();
52+
53+
if (name != "prefers-color-scheme") {
54+
frontendChannel_(
55+
cdp::jsonError(
56+
req.id,
57+
cdp::ErrorCode::MethodNotFound,
58+
"Emulation.setEmulatedMedia: unsupported media feature '" + name +
59+
"'"));
60+
return true;
61+
}
62+
63+
hasColorScheme = true;
64+
colorSchemeValue =
65+
feature.count("value") != 0u ? feature.at("value").asString() : "";
66+
}
67+
68+
if (hasColorScheme && !colorSchemeValue.empty() &&
69+
colorSchemeValue != "light" && colorSchemeValue != "dark") {
70+
frontendChannel_(
71+
cdp::jsonError(
72+
req.id,
73+
cdp::ErrorCode::InvalidParams,
74+
"Emulation.setEmulatedMedia: invalid value '" + colorSchemeValue +
75+
"' for prefers-color-scheme (expected 'light', 'dark', or '')"));
76+
return true;
77+
}
78+
79+
if (hasColorScheme) {
80+
bool success = hostTargetController_.getDelegate().onSetEmulatedMedia(
81+
{.colorScheme = colorSchemeValue});
82+
83+
if (!success) {
84+
frontendChannel_(
85+
cdp::jsonError(
86+
req.id,
87+
cdp::ErrorCode::InternalError,
88+
"Emulation.setEmulatedMedia: failed to apply color scheme override"));
89+
return true;
90+
}
91+
}
92+
93+
frontendChannel_(cdp::jsonResult(req.id));
94+
return true;
95+
}
96+
97+
} // namespace facebook::react::jsinspector_modern
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
#pragma once
9+
10+
#include "HostTarget.h"
11+
#include "InspectorInterfaces.h"
12+
13+
#include <jsinspector-modern/cdp/CdpJson.h>
14+
15+
namespace facebook::react::jsinspector_modern {
16+
17+
/**
18+
* Provides an agent for handling CDP's Emulation domain.
19+
* Currently supports Emulation.setEmulatedMedia (prefers-color-scheme only).
20+
*/
21+
class EmulationAgent {
22+
public:
23+
/**
24+
* \param frontendChannel A channel used to send responses to the
25+
* frontend.
26+
* \param hostTargetController An interface to the HostTarget that this agent
27+
* is attached to. The caller is responsible for ensuring that the
28+
* HostTargetDelegate and underlying HostTarget both outlive the agent.
29+
*/
30+
EmulationAgent(FrontendChannel frontendChannel, HostTargetController &hostTargetController);
31+
32+
/**
33+
* Handle a CDP request. The response will be sent over the provided
34+
* \c FrontendChannel synchronously or asynchronously.
35+
* \param req The parsed request.
36+
* \returns true if the request was handled.
37+
*/
38+
bool handleRequest(const cdp::PreparsedRequest &req);
39+
40+
private:
41+
FrontendChannel frontendChannel_;
42+
HostTargetController &hostTargetController_;
43+
};
44+
45+
} // namespace facebook::react::jsinspector_modern

packages/react-native/ReactCommon/jsinspector-modern/HostAgent.cpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include "InstanceAgent.h"
1010

1111
#ifdef REACT_NATIVE_DEBUGGER_ENABLED
12+
#include "EmulationAgent.h"
1213
#include "InspectorFlags.h"
1314
#include "InspectorInterfaces.h"
1415
#include "NetworkIOAgent.h"
@@ -50,7 +51,8 @@ class HostAgent::Impl final {
5051
sessionState_(sessionState),
5152
networkIOAgent_(NetworkIOAgent(frontendChannel, std::move(executor))),
5253
tracingAgent_(
53-
TracingAgent(frontendChannel, sessionState, targetController)) {}
54+
TracingAgent(frontendChannel, sessionState, targetController)),
55+
emulationAgent_(EmulationAgent(frontendChannel, targetController)) {}
5456

5557
~Impl() {
5658
if (isPausedInDebuggerOverlayVisible_) {
@@ -380,6 +382,11 @@ class HostAgent::Impl final {
380382
return;
381383
}
382384

385+
if (!requestState.isFinishedHandlingRequest &&
386+
emulationAgent_.handleRequest(req)) {
387+
return;
388+
}
389+
383390
if (!requestState.isFinishedHandlingRequest && instanceAgent_ &&
384391
instanceAgent_->handleRequest(req)) {
385392
return;
@@ -509,6 +516,8 @@ class HostAgent::Impl final {
509516
NetworkIOAgent networkIOAgent_;
510517

511518
TracingAgent tracingAgent_;
519+
520+
EmulationAgent emulationAgent_;
512521
};
513522

514523
#else

packages/react-native/ReactCommon/jsinspector-modern/HostTarget.h

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,19 @@ class HostTargetDelegate : public LoadNetworkResourceDelegate {
148148
std::optional<int> quality;
149149
};
150150

151+
struct SetEmulatedMediaRequest {
152+
/**
153+
* The color scheme to emulate: "light", "dark", or "" (reset to system
154+
* default).
155+
*/
156+
std::string colorScheme;
157+
158+
inline bool operator==(const SetEmulatedMediaRequest &rhs) const
159+
{
160+
return colorScheme == rhs.colorScheme;
161+
}
162+
};
163+
151164
virtual ~HostTargetDelegate() override;
152165

153166
/**
@@ -206,6 +219,18 @@ class HostTargetDelegate : public LoadNetworkResourceDelegate {
206219
return std::nullopt;
207220
}
208221

222+
/**
223+
* Called when the debugger requests an emulated media override via
224+
* @cdp Emulation.setEmulatedMedia. Currently only supports the
225+
* prefers-color-scheme media feature.
226+
*
227+
* \returns true if the override was applied successfully.
228+
*/
229+
virtual bool onSetEmulatedMedia(const SetEmulatedMediaRequest & /*request*/)
230+
{
231+
return false;
232+
}
233+
209234
/**
210235
* An optional delegate that will be used by HostTarget to notify about tracing-related events.
211236
*/

packages/react-native/ReactCommon/react/runtime/platform/ios/ReactCommon/RCTHost.mm

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,43 @@ void loadNetworkResource(const RCTInspectorLoadNetworkResourceRequest &params, R
141141
[networkHelper_ loadNetworkResourceWithParams:params executor:executor];
142142
}
143143

144+
#if TARGET_OS_IPHONE
145+
bool onSetEmulatedMedia(const SetEmulatedMediaRequest &request) override
146+
{
147+
RCTAssertMainQueue();
148+
UIWindow *keyWindow = nil;
149+
for (UIScene *scene in RCTSharedApplication().connectedScenes) {
150+
if (scene.activationState == UISceneActivationStateForegroundActive &&
151+
[scene isKindOfClass:[UIWindowScene class]]) {
152+
auto *windowScene = (UIWindowScene *)scene;
153+
for (UIWindow *win in windowScene.windows) {
154+
if (win.isKeyWindow) {
155+
keyWindow = win;
156+
break;
157+
}
158+
}
159+
}
160+
if (keyWindow != nil) {
161+
break;
162+
}
163+
}
164+
165+
if (keyWindow == nil) {
166+
return false;
167+
}
168+
169+
UIUserInterfaceStyle style = UIUserInterfaceStyleUnspecified;
170+
if (request.colorScheme == "dark") {
171+
style = UIUserInterfaceStyleDark;
172+
} else if (request.colorScheme == "light") {
173+
style = UIUserInterfaceStyleLight;
174+
}
175+
176+
keyWindow.overrideUserInterfaceStyle = style;
177+
return true;
178+
}
179+
#endif
180+
144181
#if TARGET_OS_IPHONE && defined(REACT_NATIVE_DEBUGGER_ENABLED)
145182
std::optional<std::string> captureScreenshot(const PageCaptureScreenshotRequest &request) override
146183
{

0 commit comments

Comments
 (0)