Compare commits

...
Author SHA1 Message Date
vii 0f6847d6d7 fix(webview): launch web Play buttons without waiting for navigation
Roblox defers a launchGame sent from the WebView until the Lua app next
navigates, so joining a private server stalled until a sidebar tab was
clicked. Parse launchGame in the runtime, queue it through the
experience launch pipeline, and close the WebView. Other commands still
reach Roblox.

Fixes #169
2026-09-24 23:01:44 -05:00
4 changed files with 155 additions and 3 deletions

No files matched your search

+7
View File
@@ -19,6 +19,13 @@ inline constexpr std::size_t kMaximumRobloxLaunchUriBytes = 64 * 1024;
Status ParseRobloxLaunchUri(std::string_view uri,
RobloxExperienceLaunchRequest* request);
// Converts a WebView launchGame command
// ({"functionName":"launchGame","params":{"request":{...}}}) into the same
// contract. Returns kUnsupported for other commands and kInvalidArgument for
// a malformed launchGame request.
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request);
} // namespace runtime
} // namespace mocktail
+18 -3
View File
@@ -11,6 +11,7 @@
#include "jnivm/jnivm.h"
#include "runtime/external_launch_broker.h"
#include "runtime/roblox_launch_uri.h"
#include "runtime/webview_helper_launcher.h"
#include "window/window.h"
@@ -951,9 +952,23 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
route = web_surface_route_;
web_view_bridge = web_view_bridge_.get();
}
return event.type == WebViewHelperEventType::kRobloxCookie
? AcceptWebViewRobloxCookie(event.payload)
: RouteWebSurfaceEvent(route, event, web_view_bridge);
if (event.type == WebViewHelperEventType::kRobloxCookie) {
return AcceptWebViewRobloxCookie(event.payload);
}
if (event.type == WebViewHelperEventType::kExecuteRoblox) {
// Roblox defers a signalled launchGame until the Lua app next navigates.
// A parsed launchGame is launched here and never forwarded, so a deferred
// copy cannot start a second join later.
RobloxExperienceLaunchRequest request;
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
Dispatch(request).ok()) {
std::fprintf(stderr,
"[experience] WebView launchGame queued place_id=%lld\n",
static_cast<long long>(request.place_id));
return CloseWebSurface();
}
}
return RouteWebSurfaceEvent(route, event, web_view_bridge);
}
Status RobloxExperienceComposition::DrainPlatformEvents() {
+59
View File
@@ -640,5 +640,64 @@ Status ParseRobloxLaunchUri(std::string_view uri,
return MakeRequest(fields, request);
}
Status ParseRobloxWebViewLaunchGame(std::string_view command,
RobloxExperienceLaunchRequest* request) {
if (request == nullptr) {
return Invalid("Roblox launchGame output is null");
}
*request = {};
const nlohmann::json document =
command.size() <= kMaximumRobloxLaunchUriBytes
? nlohmann::json::parse(command, nullptr, false)
: nlohmann::json();
const auto function_name = document.is_object()
? document.find("functionName")
: document.end();
if (!document.is_object() || function_name == document.end() ||
*function_name != "launchGame") {
return Status::Error(StatusCode::kUnsupported,
"WebView command is not launchGame");
}
const auto params = document.find("params");
if (params == document.end() || !params->is_object()) {
return Invalid("Roblox launchGame has no params");
}
const auto launch = params->find("request");
if (launch == params->end() || !launch->is_object()) {
return Invalid("Roblox launchGame has no request");
}
if (launch->size() > kMaximumParameterCount) {
return Invalid("Roblox launchGame request has too many fields");
}
LaunchFields fields;
for (const auto& [key, value] : launch->items()) {
std::string text;
if (value.is_string()) {
text = value.get<std::string>();
} else if (value.is_number_unsigned()) {
text = std::to_string(value.get<uint64_t>());
} else {
continue;
}
if (text.size() > kMaximumParameterValueBytes ||
ContainsControlBytes(text)) {
return Invalid("Roblox launchGame field is invalid");
}
Status status = AssignField(key == "requestType" ? "request" : key,
std::move(text), false, &fields);
if (!status.ok()) {
return status;
}
}
if (fields.request_type.empty()) {
return Invalid("Roblox launchGame request type is missing");
}
Status status = MakeRequest(fields, request);
if (!status.ok()) {
*request = {};
}
return status;
}
} // namespace runtime
} // namespace mocktail
+71
View File
@@ -322,6 +322,77 @@ TEST(RobloxLaunchUriTest, ClearsOutputAndRedactsTicketOnFailure) {
EXPECT_FALSE(ParseRobloxLaunchUri(kModernPublicLaunchUri, nullptr).ok());
}
TEST(RobloxWebViewLaunchGameTest, ParsesPrivateServerPlayButton) {
constexpr char kCommand[] =
R"({"moduleID":"Game","functionName":"launchGame",)"
R"("callbackID":"b8cf5fbd-f6a8-cfba-2cae-84bce46f46b2",)"
R"("params":{"request":{"requestType":"RequestPrivateGame",)"
R"("browserTrackerId":"1790271319250009",)"
R"("accessCode":"664b524f-19cc-4e28-a947-e8f5286f4179",)"
R"("placeId":"920587237","joinAttemptId":"attempt-1",)"
R"("joinAttemptOrigin":"privateServerListJoin",)"
R"("isPlayTogetherGame":false}}})";
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(kCommand, &request);
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(request.place_id, 920587237);
EXPECT_EQ(request.access_code, "664b524f-19cc-4e28-a947-e8f5286f4179");
EXPECT_EQ(request.join_attempt_id, "attempt-1");
EXPECT_EQ(request.join_attempt_origin, "privateServerListJoin");
EXPECT_EQ(request.canonical_json.find("browserTrackerId"), std::string::npos);
ExpectCanonicalRequestMatches(request);
}
TEST(RobloxWebViewLaunchGameTest, ParsesServerJobWithNumericPlaceId) {
constexpr char kCommand[] =
R"({"moduleID":"Game","functionName":"launchGame",)"
R"("params":{"request":{"requestType":"RequestGameJob",)"
R"("placeId":1962086868,"gameId":"97ea-49e4"}}})";
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(kCommand, &request);
ASSERT_TRUE(status.ok()) << status.message();
EXPECT_EQ(request.place_id, 1962086868);
EXPECT_EQ(request.game_instance_id, "97ea-49e4");
ExpectCanonicalRequestMatches(request);
}
TEST(RobloxWebViewLaunchGameTest, ReportsOtherCommandsAsUnsupported) {
RobloxExperienceLaunchRequest request;
request.place_id = 123;
request.canonical_json = "stale";
const Status status = ParseRobloxWebViewLaunchGame(
R"({"moduleID":"Game","functionName":"getSystemInfo","params":{}})",
&request);
EXPECT_EQ(status.code(), StatusCode::kUnsupported);
EXPECT_EQ(request.place_id, 0);
EXPECT_TRUE(request.canonical_json.empty());
EXPECT_EQ(ParseRobloxWebViewLaunchGame("not json", &request).code(),
StatusCode::kUnsupported);
}
TEST(RobloxWebViewLaunchGameTest, RejectsMalformedLaunchGame) {
const char* const kCommands[] = {
R"({"functionName":"launchGame"})",
R"({"functionName":"launchGame","params":{"request":"x"}})",
R"({"functionName":"launchGame","params":{"request":{)"
R"("requestType":"RequestPrivateGame","placeId":"920587237"}}})",
R"({"functionName":"launchGame","params":{"request":{)"
R"("requestType":"RequestGame","placeId":"-1"}}})",
};
for (const char* command : kCommands) {
RobloxExperienceLaunchRequest request;
const Status status = ParseRobloxWebViewLaunchGame(command, &request);
EXPECT_EQ(status.code(), StatusCode::kInvalidArgument) << command;
EXPECT_EQ(request.place_id, 0) << command;
}
}
} // namespace
} // namespace runtime
} // namespace mocktail