mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 23:00:46 +02:00
fix(webview): keep Roblox's WebViewProtocol alive so login challenges finish
Roblox's WebViewProtocol is a counted subsystem that Android's app start holds for the process lifetime. Nightcap held no handle, so the protocol and its handleJavascriptCallback subscriber were deleted after each use. Page messages such as challengeCompleted then waited for the next Lua WebView call, which came only at the 60 s challenge timeout, and login failed. Find the unexported getter through its call site in initializeAndroidWebViewProtocol, check both functions against x86-64 and AArch64 code contracts, and hold one handle before native init. The helper no longer closes a solved challenge before the host reads its events. Roblox closes WebViewProtocol challenges itself, with a 3 s host fallback; OPEN_CAPTCHA_VIEW windows close once success is forwarded. Solved-challenge feature names now live in one shared header.
This commit is contained in:
1 parent
01a0e40463
commit
26767e925c
15 files changed
+897
-31
No files matched your search
@@ -22,6 +22,11 @@ GitHub are taken from the matching section here.
|
||||
- Solving a login challenge now closes its window. Before, Roblox's
|
||||
"challenge completed" signal was ignored and the window stayed open.
|
||||
Thanks to @glook9001.
|
||||
- Login no longer stalls after you pass a verification check. Before, Roblox
|
||||
only saw the page's "challenge completed" signal after a one-minute
|
||||
timeout, so it gave up and the login failed. On a Roblox build Nightcap
|
||||
can't handle this way, the log says "WebViewProtocol getter not
|
||||
recognized" and web pages behave as before.
|
||||
- Login check windows and other web pages get 5 seconds to start instead of
|
||||
3, so a slow start no longer closes them. The log says how long each one
|
||||
took to open.
|
||||
|
||||
+2
-1
@@ -386,7 +386,8 @@ add_library(mocktail_webview_helper_policy STATIC
|
||||
src/webview/webview_helper_policy.cc
|
||||
)
|
||||
add_library(Mocktail::WebViewHelperPolicy ALIAS mocktail_webview_helper_policy)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src)
|
||||
target_include_directories(mocktail_webview_helper_policy PUBLIC src
|
||||
PRIVATE include)
|
||||
target_link_libraries(mocktail_webview_helper_policy PUBLIC
|
||||
PkgConfig::WEBKITGTK6
|
||||
nlohmann_json::nlohmann_json
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
#ifndef MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
#define MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <optional>
|
||||
#include <string_view>
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter is not exported. The exported
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol calls it first, into a
|
||||
// stack slot, then reads the protocol from the returned holder:
|
||||
//
|
||||
// x86-64: lea slot(%rbp),%rbx; mov %rbx,%rdi; call getter;
|
||||
// mov (%rbx),%rbx; mov 0x18(%rbx),%rax
|
||||
// AArch64: add x8, sp, #slot; bl getter; ldr xN, [sp, #slot];
|
||||
// ldr x8, [xN, #0x18]
|
||||
//
|
||||
// These functions find that call site in the initializer's first bytes and
|
||||
// check the getter's own code. Only relocated or build-varying fields (stack
|
||||
// slots, frame sizes, PC-relative offsets, the call target) are masked.
|
||||
// Verified on x86_64 2.734.917, 2.736.1408 and 2.738.1397, and on arm64-v8a
|
||||
// 2.738.1397 and 2.740.931.
|
||||
|
||||
namespace mocktail::compat {
|
||||
|
||||
// Covers the call site in every verified build (x86-64 +0x27, AArch64 +0x34).
|
||||
inline constexpr std::size_t kWebViewProtocolInitializerScanBytes = 128;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesX86_64 = 44;
|
||||
inline constexpr std::size_t kWebViewProtocolGetterBytesArm64 = 60;
|
||||
|
||||
namespace web_view_protocol_contract_detail {
|
||||
|
||||
inline bool MatchesMasked(std::string_view code, std::size_t offset,
|
||||
std::string_view pattern,
|
||||
std::string_view wildcard) {
|
||||
if (offset > code.size() || code.size() - offset < pattern.size()) {
|
||||
return false;
|
||||
}
|
||||
for (std::size_t index = 0; index < pattern.size(); ++index) {
|
||||
if (wildcard[index] == 'x') continue;
|
||||
if (code[offset + index] != pattern[index]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// AArch64 instructions are little-endian words.
|
||||
inline std::uint32_t Word(std::string_view code, std::size_t index) {
|
||||
std::uint32_t word = 0;
|
||||
for (std::size_t byte = 0; byte < 4; ++byte) {
|
||||
word |= static_cast<std::uint32_t>(
|
||||
static_cast<unsigned char>(code[index * 4 + byte]))
|
||||
<< (8 * byte);
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
// One AArch64 instruction word: bits outside `mask` may vary.
|
||||
struct MaskedWord {
|
||||
std::uint32_t mask;
|
||||
std::uint32_t value;
|
||||
};
|
||||
|
||||
inline constexpr std::uint32_t kExact = 0xffffffffU;
|
||||
// adrp Xd, page: immlo and immhi vary.
|
||||
inline constexpr std::uint32_t kAdrpMask = 0x9f00001fU;
|
||||
// add Xd, Xn, #imm12: imm12 varies.
|
||||
inline constexpr std::uint32_t kAddImmediateMask = 0xffc003ffU;
|
||||
|
||||
} // namespace web_view_protocol_contract_detail
|
||||
|
||||
// Returns the getter address called from the x86-64 initializer at
|
||||
// `address`, or nullopt unless the frame setup and exactly one call site
|
||||
// match.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallX86_64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::MatchesMasked;
|
||||
// push %rbp; mov %rsp,%rbp
|
||||
if (!MatchesMasked(code, 0, "\x55\x48\x89\xe5", "....")) {
|
||||
return std::nullopt;
|
||||
}
|
||||
static constexpr std::string_view kCallSite(
|
||||
"\x48\x8d\x5d\x00\x48\x89\xdf\xe8\x00\x00\x00\x00\x48\x8b\x1b\x48\x8b\x43"
|
||||
"\x18",
|
||||
19);
|
||||
static constexpr std::string_view kCallSiteWildcard("...x....xxxx.......");
|
||||
static constexpr std::size_t kCallDisplacement = 8;
|
||||
const std::string_view window =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes);
|
||||
std::optional<std::size_t> call_site;
|
||||
for (std::size_t offset = 0; offset < window.size(); ++offset) {
|
||||
if (MatchesMasked(window, offset, kCallSite, kCallSiteWildcard)) {
|
||||
if (call_site.has_value()) return std::nullopt;
|
||||
call_site = offset;
|
||||
}
|
||||
}
|
||||
if (!call_site.has_value()) return std::nullopt;
|
||||
std::int32_t displacement = 0;
|
||||
std::memcpy(&displacement, window.data() + *call_site + kCallDisplacement,
|
||||
sizeof(displacement));
|
||||
const std::uintptr_t next_instruction =
|
||||
address + *call_site + kCallDisplacement + sizeof(displacement);
|
||||
return next_instruction +
|
||||
static_cast<std::uintptr_t>(static_cast<std::intptr_t>(displacement));
|
||||
}
|
||||
|
||||
// The x86-64 getter stores the holder returned by the subsystem's acquire
|
||||
// into its result slot (%rdi) and returns that slot.
|
||||
inline bool HasWebViewProtocolGetterContractX86_64(std::string_view code) {
|
||||
return web_view_protocol_contract_detail::MatchesMasked(
|
||||
code, 0,
|
||||
std::string_view(
|
||||
"\x55\x48\x89\xe5\x53\x50\x48\x89\xfb\x8a\x05\x00\x00\x00\x00\x84\xc0"
|
||||
"\x74\x19\x48\x8d\x3d\x00\x00\x00\x00\xe8\x59\x00\x00\x00\x48\x89\x03"
|
||||
"\x48\x89\xd8\x48\x83\xc4\x08\x5b\x5d\xc3",
|
||||
kWebViewProtocolGetterBytesX86_64),
|
||||
"...........xxxx.......xxxx..................");
|
||||
}
|
||||
|
||||
// Returns the getter address called from the AArch64 initializer at
|
||||
// `address`, or nullopt unless exactly one call site matches.
|
||||
inline std::optional<std::uintptr_t> FindWebViewProtocolGetterCallArm64(
|
||||
std::string_view code, std::uintptr_t address) {
|
||||
using web_view_protocol_contract_detail::Word;
|
||||
const std::size_t words =
|
||||
code.substr(0, kWebViewProtocolInitializerScanBytes).size() / 4;
|
||||
std::optional<std::uintptr_t> getter;
|
||||
for (std::size_t index = 0; index + 4 <= words; ++index) {
|
||||
const std::uint32_t slot_address = Word(code, index);
|
||||
const std::uint32_t call = Word(code, index + 1);
|
||||
const std::uint32_t handle_load = Word(code, index + 2);
|
||||
const std::uint32_t protocol_load = Word(code, index + 3);
|
||||
// add x8, sp, #slot
|
||||
if ((slot_address & 0xffc003ffU) != 0x910003e8U) continue;
|
||||
// bl getter
|
||||
if ((call & 0xfc000000U) != 0x94000000U) continue;
|
||||
// ldr xN, [sp, #slot]
|
||||
if ((handle_load & 0xffc003e0U) != 0xf94003e0U) continue;
|
||||
const std::uint32_t slot = (slot_address >> 10) & 0xfffU;
|
||||
if (((handle_load >> 10) & 0xfffU) * 8 != slot) continue;
|
||||
// ldr x8, [xN, #0x18]
|
||||
const std::uint32_t handle_register = handle_load & 0x1fU;
|
||||
if (protocol_load != (0xf9400c08U | (handle_register << 5))) continue;
|
||||
if (getter.has_value()) return std::nullopt;
|
||||
// imm26 is a signed word offset from the bl instruction.
|
||||
const std::int64_t imm26 = call & 0x03ffffffU;
|
||||
const std::int64_t words_from_call =
|
||||
(imm26 & 0x02000000) != 0 ? imm26 - 0x04000000 : imm26;
|
||||
getter = address + (index + 1) * 4 +
|
||||
static_cast<std::uintptr_t>(words_from_call * 4);
|
||||
}
|
||||
return getter;
|
||||
}
|
||||
|
||||
// The AArch64 getter saves its result slot (x8) and stores the holder
|
||||
// returned by the subsystem's acquire there.
|
||||
inline bool HasWebViewProtocolGetterContractArm64(std::string_view code) {
|
||||
using namespace web_view_protocol_contract_detail;
|
||||
static constexpr MaskedWord kGetter[] = {
|
||||
{kExact, 0xa9be7bfdU}, // stp x29, x30, [sp, #-0x20]!
|
||||
{kExact, 0xf9000bf3U}, // str x19, [sp, #0x10]
|
||||
{kExact, 0x910003fdU}, // mov x29, sp
|
||||
{kAdrpMask, 0x90000009U}, // adrp x9, guard
|
||||
{kAddImmediateMask, 0x91000129U}, // add x9, x9, :lo12:guard
|
||||
{kExact, 0xaa0803f3U}, // mov x19, x8
|
||||
{kExact, 0x08dffd29U}, // ldarb w9, [x9]
|
||||
{kExact, 0x36000109U}, // tbz w9, #0, initialize
|
||||
{kAdrpMask, 0x90000000U}, // adrp x0, holder
|
||||
{kAddImmediateMask, 0x91000000U}, // add x0, x0, :lo12:holder
|
||||
{kExact, 0x94000017U}, // bl acquire
|
||||
{kExact, 0xf9000260U}, // str x0, [x19]
|
||||
{kExact, 0xf9400bf3U}, // ldr x19, [sp, #0x10]
|
||||
{kExact, 0xa8c27bfdU}, // ldp x29, x30, [sp], #0x20
|
||||
{kExact, 0xd65f03c0U}, // ret
|
||||
};
|
||||
static_assert(sizeof(kGetter) / sizeof(kGetter[0]) * 4 ==
|
||||
kWebViewProtocolGetterBytesArm64);
|
||||
if (code.size() < kWebViewProtocolGetterBytesArm64) return false;
|
||||
for (std::size_t index = 0; index < sizeof(kGetter) / sizeof(kGetter[0]);
|
||||
++index) {
|
||||
if ((Word(code, index) & kGetter[index].mask) != kGetter[index].value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace mocktail::compat
|
||||
|
||||
#endif // MOCKTAIL_COMPAT_WEB_VIEW_PROTOCOL_CONTRACT_H_
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <deque>
|
||||
#include <memory>
|
||||
@@ -164,6 +165,7 @@ class RobloxExperienceComposition final {
|
||||
bool visible = true;
|
||||
bool back_navigation_disabled = false;
|
||||
bool show_domain_as_title = false;
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
static Status DispatchLaunch(void* context,
|
||||
@@ -203,8 +205,11 @@ class RobloxExperienceComposition final {
|
||||
const WebSurfacePresentation& presentation);
|
||||
// WebViewProtocol and BrowserService are two logical transports over the
|
||||
// APK's single physical WebView container. Either protocol may close or
|
||||
// mutate the surface that the other one opened.
|
||||
Status CloseWebSurface();
|
||||
// mutate the surface that the other one opened. A nonzero
|
||||
// expected_logical_generation closes only that surface.
|
||||
Status CloseWebSurface(uint64_t expected_logical_generation = 0);
|
||||
// Closes a solved challenge that Roblox left open past its grace period.
|
||||
Status CloseUnclaimedSolvedChallenge();
|
||||
void HandleWebSurfaceExit(uint64_t process_generation);
|
||||
Status Dispatch(const RobloxExperienceLaunchRequest& request);
|
||||
Status PromoteAuthenticatedSession();
|
||||
@@ -262,6 +267,13 @@ class RobloxExperienceComposition final {
|
||||
uint64_t web_surface_logical_generation_ = 0;
|
||||
uint64_t next_web_surface_process_generation_ = 1;
|
||||
uint64_t next_web_surface_logical_generation_ = 1;
|
||||
// The current surface closes as soon as its solved challenge is forwarded.
|
||||
bool web_surface_close_when_challenge_solved_ = false;
|
||||
// Set when a solved challenge is forwarded; applies only to that logical
|
||||
// surface generation.
|
||||
std::optional<std::chrono::steady_clock::time_point>
|
||||
solved_challenge_close_deadline_;
|
||||
uint64_t solved_challenge_logical_generation_ = 0;
|
||||
SecureWebViewRobloxCookie web_view_cookie_;
|
||||
std::string web_view_cookie_initialization_error_;
|
||||
bool web_view_cookie_synchronized_ = false;
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <type_traits>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
@@ -42,6 +44,10 @@ struct RobloxWebViewOpenRequest {
|
||||
std::optional<bool> show_domain_as_title;
|
||||
std::optional<bool> back_button_visible;
|
||||
std::optional<bool> hide_header;
|
||||
// Set by the bridge, never parsed. The host closes an OPEN_CAPTCHA_VIEW
|
||||
// window once its solved captcha has been forwarded, as Android's captcha
|
||||
// activity finishes itself on success.
|
||||
bool close_when_challenge_solved = false;
|
||||
};
|
||||
|
||||
// WebViewProtocol.mutateWindow is a partial update. Field absence must remain
|
||||
@@ -61,6 +67,9 @@ Status ParseRobloxWebViewMutationJson(const std::string& json,
|
||||
RobloxWebViewMutationRequest* request);
|
||||
Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
// True for a page's Navigation.navigateToFeature command reporting a solved
|
||||
// verification (see webview::IsRobloxChallengeSolvedFeature).
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command);
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request);
|
||||
Status ParseRobloxPurchaseRobuxNotification(const std::string& json,
|
||||
@@ -108,6 +117,33 @@ using BroadcastWebViewDataModelFocusFn = void (*)(JNIEnv *, jclass, jstring,
|
||||
using SignalWebViewJavascriptCallbackFn = void (*)(JNIEnv*, jclass, jstring);
|
||||
using UpdateRobloxCookieSetHandlerFn =
|
||||
void (*)(JNIEnv*, jobject, jobject);
|
||||
// Roblox's counted handle to its Subsystem<IWebViewProtocol>. The protocol,
|
||||
// and its handleJavascriptCallback subscriber, exist only while a handle is
|
||||
// held. Roblox's handle type is not trivially destructible, so its getter
|
||||
// returns it through the caller-supplied result slot (rdi on x86-64, x8 on
|
||||
// AArch64). The user-provided destructor gives this type the same calling
|
||||
// convention. It does not release the handle.
|
||||
class RobloxWebViewProtocolHandle final {
|
||||
public:
|
||||
RobloxWebViewProtocolHandle() = default;
|
||||
explicit RobloxWebViewProtocolHandle(void* holder) : holder_(holder) {}
|
||||
RobloxWebViewProtocolHandle(const RobloxWebViewProtocolHandle&) = default;
|
||||
RobloxWebViewProtocolHandle& operator=(const RobloxWebViewProtocolHandle&) =
|
||||
default;
|
||||
~RobloxWebViewProtocolHandle() {}
|
||||
|
||||
void* holder() const { return holder_; }
|
||||
|
||||
private:
|
||||
void* holder_ = nullptr;
|
||||
};
|
||||
static_assert(sizeof(RobloxWebViewProtocolHandle) == sizeof(void*));
|
||||
static_assert(!std::is_trivially_destructible_v<RobloxWebViewProtocolHandle>);
|
||||
|
||||
// Roblox's Subsystem<IWebViewProtocol> getter. It is not exported; the
|
||||
// resolver finds it through the first call in
|
||||
// WebViewProtocol.initializeAndroidWebViewProtocol.
|
||||
using AcquireWebViewProtocolFn = RobloxWebViewProtocolHandle (*)();
|
||||
|
||||
struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewOpenWindowIdFn get_open_window_id = nullptr;
|
||||
@@ -129,6 +165,8 @@ struct RobloxWebViewMessageBusSymbols {
|
||||
GetWebViewCloseWindowIdFn get_close_window_id = nullptr;
|
||||
SignalWebViewJavascriptCallbackFn signal_javascript_callback = nullptr;
|
||||
UpdateRobloxCookieSetHandlerFn update_cookie_set_handler = nullptr;
|
||||
// Optional: found by code contract, not by symbol name.
|
||||
AcquireWebViewProtocolFn acquire_web_view_protocol = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return get_open_window_id != nullptr &&
|
||||
@@ -287,6 +325,10 @@ private:
|
||||
std::array<std::shared_ptr<RawCallbackTarget>, kSubscriptionCount>
|
||||
raw_callback_targets_{};
|
||||
std::shared_ptr<HostWindowCloseTarget> host_window_close_target_;
|
||||
// The reference this counts is kept for the process lifetime, as Roblox's
|
||||
// Android app start keeps its own. Neither Shutdown nor destruction
|
||||
// releases it.
|
||||
RobloxWebViewProtocolHandle web_view_protocol_handle_;
|
||||
std::size_t in_flight_dispatches_ = 0;
|
||||
bool initializing_ = false;
|
||||
bool shutting_down_ = false;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
#ifndef MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
#define MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::webview {
|
||||
|
||||
// Navigation.navigateToFeature features a Roblox verification page sends when
|
||||
// the user has solved it. The captcha page sends CaptchaSuccess; the generic
|
||||
// challenge page (challenge/cdn/hybrid) sends challengeCompleted. The WebView
|
||||
// helper and the runtime both classify page events with this list.
|
||||
inline bool IsRobloxChallengeSolvedFeature(std::string_view feature) {
|
||||
return feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
|
||||
feature == "ChallengeCompleted";
|
||||
}
|
||||
|
||||
} // namespace mocktail::webview
|
||||
|
||||
#endif // MOCKTAIL_WEBVIEW_ROBLOX_CHALLENGE_FEATURES_H_
|
||||
@@ -24,6 +24,11 @@ constexpr size_t kMaxPendingLaunchRequests = 8;
|
||||
constexpr size_t kLaunchWorkerStackSize = 64ULL * 1024 * 1024;
|
||||
// Covers helper exec through window creation, including cold Flatpak starts.
|
||||
constexpr std::chrono::milliseconds kWebSurfaceReadyTimeout{5000};
|
||||
// Roblox closes a solved WebViewProtocol challenge itself with closeWindow,
|
||||
// as on Android. The host closes it only if Roblox has not done so within
|
||||
// this period. Captcha windows close at once instead (see
|
||||
// RobloxWebViewOpenRequest::close_when_challenge_solved).
|
||||
constexpr std::chrono::milliseconds kSolvedChallengeCloseGrace{3000};
|
||||
constexpr char kRobloxBaseUrl[] = "https://www.roblox.com/";
|
||||
|
||||
Status Invalid(std::string message) {
|
||||
@@ -637,6 +642,8 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
web_surface_process_generation_ = process_generation;
|
||||
web_surface_route_ = route;
|
||||
web_surface_logical_generation_ = logical_generation;
|
||||
web_surface_close_when_challenge_solved_ =
|
||||
presentation.close_when_challenge_solved;
|
||||
web_surface_logical_exit_observer_ = std::move(exit_observer);
|
||||
}
|
||||
if (superseded_observer.valid()) {
|
||||
@@ -649,13 +656,16 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
Status RobloxExperienceComposition::CloseWebSurface(
|
||||
uint64_t expected_logical_generation) {
|
||||
std::lock_guard<std::mutex> operation_lock(web_surface_operation_mutex_);
|
||||
std::shared_ptr<WebViewHelperProcess> process;
|
||||
WebViewHelperExitObserver exit_observer;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone) {
|
||||
if (web_surface_route_ == WebSurfaceRoute::kNone ||
|
||||
(expected_logical_generation != 0 &&
|
||||
expected_logical_generation != web_surface_logical_generation_)) {
|
||||
return Status::Ok();
|
||||
}
|
||||
process = std::move(web_surface_process_);
|
||||
@@ -663,6 +673,7 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
}
|
||||
// A closed challenge must stop executing. Reusing its hidden document lets
|
||||
// late callbacks from the old attempt reach the next login attempt. The APK
|
||||
@@ -678,6 +689,33 @@ Status RobloxExperienceComposition::CloseWebSurface() {
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::CloseUnclaimedSolvedChallenge() {
|
||||
uint64_t generation = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!solved_challenge_close_deadline_.has_value()) {
|
||||
return Status::Ok();
|
||||
}
|
||||
const bool still_open =
|
||||
web_surface_route_ != WebSurfaceRoute::kNone &&
|
||||
solved_challenge_logical_generation_ == web_surface_logical_generation_;
|
||||
if (still_open &&
|
||||
std::chrono::steady_clock::now() < *solved_challenge_close_deadline_) {
|
||||
return Status::Ok();
|
||||
}
|
||||
solved_challenge_close_deadline_.reset();
|
||||
if (!still_open) {
|
||||
return Status::Ok();
|
||||
}
|
||||
generation = solved_challenge_logical_generation_;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [webview] closing solved challenge window; Roblox did not "
|
||||
"close it within %lld ms\n",
|
||||
static_cast<long long>(kSolvedChallengeCloseGrace.count()));
|
||||
return CloseWebSurface(generation);
|
||||
}
|
||||
|
||||
void RobloxExperienceComposition::WebSurfaceExited(void* context) {
|
||||
auto* exit_context = static_cast<WebSurfaceExitContext*>(context);
|
||||
if (exit_context == nullptr || exit_context->target == nullptr ||
|
||||
@@ -703,6 +741,7 @@ void RobloxExperienceComposition::HandleWebSurfaceExit(
|
||||
web_surface_process_generation_ = 0;
|
||||
web_surface_route_ = WebSurfaceRoute::kNone;
|
||||
web_surface_logical_generation_ = 0;
|
||||
web_surface_close_when_challenge_solved_ = false;
|
||||
exit_observer = std::move(web_surface_logical_exit_observer_);
|
||||
}
|
||||
if (exit_observer.valid()) {
|
||||
@@ -728,6 +767,8 @@ Status RobloxExperienceComposition::DispatchWebViewOpen(
|
||||
// host back navigation too.
|
||||
presentation.back_navigation_disabled =
|
||||
request.back_button_visible.has_value() && !*request.back_button_visible;
|
||||
presentation.close_when_challenge_solved =
|
||||
request.close_when_challenge_solved;
|
||||
return composition->OpenWebSurface(request.url, "webview",
|
||||
WebSurfaceRoute::kWebView,
|
||||
std::move(exit_observer), presentation);
|
||||
@@ -990,9 +1031,9 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
|
||||
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.
|
||||
// A web Play button's launchGame is launched through the experience
|
||||
// pipeline and closes the surface. It is never forwarded, so Roblox
|
||||
// cannot start a second join from the same command.
|
||||
RobloxExperienceLaunchRequest request;
|
||||
if (ParseRobloxWebViewLaunchGame(event.payload, &request).ok() &&
|
||||
Dispatch(request).ok()) {
|
||||
@@ -1002,7 +1043,29 @@ Status RobloxExperienceComposition::RouteCurrentWebSurfaceEvent(
|
||||
return CloseWebSurface();
|
||||
}
|
||||
}
|
||||
return RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
const Status status = RouteWebSurfaceEvent(route, event, web_view_bridge);
|
||||
if (!status.ok() || !IsRobloxWebViewChallengeSuccess(event.payload)) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] solved challenge forwarded to Roblox\n");
|
||||
bool close_now = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (logical_generation != web_surface_logical_generation_) {
|
||||
return status;
|
||||
}
|
||||
close_now = web_surface_close_when_challenge_solved_;
|
||||
if (!close_now) {
|
||||
solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now() + kSolvedChallengeCloseGrace;
|
||||
solved_challenge_logical_generation_ = logical_generation;
|
||||
}
|
||||
}
|
||||
if (!close_now) {
|
||||
return status;
|
||||
}
|
||||
std::fprintf(stderr, " [webview] closing solved captcha window\n");
|
||||
return CloseWebSurface(logical_generation);
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
@@ -1051,6 +1114,9 @@ Status RobloxExperienceComposition::DrainPlatformEvents() {
|
||||
status = drain_helper(web_surface_process, process_generation,
|
||||
logical_generation);
|
||||
}
|
||||
if (status.ok()) {
|
||||
status = CloseUnclaimedSolvedChallenge();
|
||||
}
|
||||
if (status.ok() && browser_service_bridge != nullptr) {
|
||||
status = browser_service_bridge->DrainOutgoingEvents();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include "runtime/roblox_platform_web_symbols.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "compat/web_view_protocol_contract.h"
|
||||
#include "linker/linker.h"
|
||||
|
||||
namespace mocktail {
|
||||
@@ -14,6 +18,41 @@ Function Resolve(void* library, const char* name) {
|
||||
linker::ResolveSymbol(library, std::string(name)));
|
||||
}
|
||||
|
||||
// Reads the mapped initializer and follows its getter call only when both
|
||||
// match the code contract for this host's architecture. Both reads stay
|
||||
// inside libroblox.so's text segment: the initializer is longer than the scan
|
||||
// window, and the getter address comes from a verified call site.
|
||||
AcquireWebViewProtocolFn FindWebViewProtocolGetter(const void* initializer) {
|
||||
if (initializer == nullptr) return nullptr;
|
||||
const auto address = reinterpret_cast<std::uintptr_t>(initializer);
|
||||
const std::string_view code(static_cast<const char*>(initializer),
|
||||
compat::kWebViewProtocolInitializerScanBytes);
|
||||
#if defined(__x86_64__)
|
||||
const std::optional<std::uintptr_t> getter =
|
||||
compat::FindWebViewProtocolGetterCallX86_64(code, address);
|
||||
const bool getter_matches =
|
||||
getter.has_value() &&
|
||||
compat::HasWebViewProtocolGetterContractX86_64(std::string_view(
|
||||
reinterpret_cast<const char*>(*getter),
|
||||
compat::kWebViewProtocolGetterBytesX86_64));
|
||||
#elif defined(__aarch64__)
|
||||
const std::optional<std::uintptr_t> getter =
|
||||
compat::FindWebViewProtocolGetterCallArm64(code, address);
|
||||
const bool getter_matches =
|
||||
getter.has_value() &&
|
||||
compat::HasWebViewProtocolGetterContractArm64(std::string_view(
|
||||
reinterpret_cast<const char*>(*getter),
|
||||
compat::kWebViewProtocolGetterBytesArm64));
|
||||
#else
|
||||
const std::optional<std::uintptr_t> getter;
|
||||
const bool getter_matches = false;
|
||||
(void)code;
|
||||
(void)address;
|
||||
#endif
|
||||
return getter_matches ? reinterpret_cast<AcquireWebViewProtocolFn>(*getter)
|
||||
: nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
@@ -46,6 +85,9 @@ RobloxPlatformWebSymbols ResolveRobloxPlatformWebSymbols(
|
||||
roblox_library,
|
||||
"Java_com_roblox_protocols_webview_WebViewProtocol_"
|
||||
"initializeAndroidWebViewProtocol");
|
||||
symbols.web_view.acquire_web_view_protocol = FindWebViewProtocolGetter(
|
||||
reinterpret_cast<const void*>(
|
||||
symbols.web_view.initialize_android_web_view_protocol));
|
||||
symbols.web_view.subscribe_raw = subscribe_raw;
|
||||
symbols.web_view.delete_connection = delete_connection;
|
||||
symbols.web_view.set_request_handler_raw =
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <utility>
|
||||
|
||||
#include "runtime/environment.h"
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -401,6 +402,39 @@ Status ParseRobloxCaptchaOpenJson(const std::string& json,
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
bool IsRobloxWebViewChallengeSuccess(std::string_view command) {
|
||||
if (command.empty() || command.size() > kMaximumRobloxWebViewJsonBytes) {
|
||||
return false;
|
||||
}
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(command.begin(), command.end(), nullptr, false);
|
||||
const auto string_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const std::string* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_string()
|
||||
? &field->get_ref<const std::string&>()
|
||||
: nullptr;
|
||||
};
|
||||
const auto object_field = [](const nlohmann::json& object,
|
||||
const char* key) -> const nlohmann::json* {
|
||||
const auto field = object.find(key);
|
||||
return field != object.end() && field->is_object() ? &*field : nullptr;
|
||||
};
|
||||
if (!document.is_object()) {
|
||||
return false;
|
||||
}
|
||||
const std::string* module = string_field(document, "moduleID");
|
||||
const std::string* function = string_field(document, "functionName");
|
||||
const nlohmann::json* outer = object_field(document, "params");
|
||||
const nlohmann::json* navigation =
|
||||
outer != nullptr ? object_field(*outer, "params") : nullptr;
|
||||
const std::string* feature =
|
||||
navigation != nullptr ? string_field(*navigation, "feature") : nullptr;
|
||||
return module != nullptr && *module == "Navigation" && function != nullptr &&
|
||||
*function == "navigateToFeature" && feature != nullptr &&
|
||||
webview::IsRobloxChallengeSolvedFeature(*feature);
|
||||
}
|
||||
|
||||
Status ParseRobloxProfileViewUrl(const std::string& url,
|
||||
RobloxWebViewOpenRequest* request) {
|
||||
if (request == nullptr) {
|
||||
@@ -911,6 +945,20 @@ Status RobloxWebViewBridge::Initialize() {
|
||||
// handler and window subscriptions are installed before the native Android
|
||||
// protocol is initialized. Calling the native initializer earlier loses
|
||||
// messages that it can publish synchronously during startup.
|
||||
// Roblox's Android app start holds a WebViewProtocol handle for the process
|
||||
// lifetime. Without one, the protocol is deleted as soon as each caller
|
||||
// releases it, and page callbacks wait for the next Lua WebView call to
|
||||
// recreate it. Taking it before the native initializer keeps the protocol
|
||||
// that initializer configures.
|
||||
if (symbols_.acquire_web_view_protocol != nullptr) {
|
||||
if (web_view_protocol_handle_.holder() == nullptr) {
|
||||
web_view_protocol_handle_ = symbols_.acquire_web_view_protocol();
|
||||
}
|
||||
} else {
|
||||
std::fprintf(stderr,
|
||||
" [webview] WebViewProtocol getter not recognized; page "
|
||||
"callbacks may wait for the next Roblox WebView call\n");
|
||||
}
|
||||
jclass initialization_class = env->FindClass(kWebViewProtocolClass);
|
||||
if (initialization_class != nullptr) {
|
||||
symbols_.initialize_android_web_view_protocol(env, initialization_class);
|
||||
@@ -1266,6 +1314,7 @@ Status RobloxWebViewBridge::DispatchOpenRequest(
|
||||
exit_context->target->browser_login_fallback = browser_login_fallback;
|
||||
}
|
||||
}
|
||||
request.close_when_challenge_solved = source == OpenSource::kCaptcha;
|
||||
if (status.ok()) {
|
||||
status = sink_.dispatch_open(
|
||||
sink_.context, request,
|
||||
|
||||
@@ -108,15 +108,9 @@ struct SurfaceState {
|
||||
unsigned int resource_retries = 0;
|
||||
};
|
||||
|
||||
// A solved challenge stays open: the host forwards the event to Roblox, and
|
||||
// Roblox or the host closes the window after Roblox has received it.
|
||||
struct CallbackConfirmation {
|
||||
CallbackConfirmation(GtkWindow* surface_window, CaptchaEventType event_type)
|
||||
: type(event_type) {
|
||||
g_weak_ref_init(&window, G_OBJECT(surface_window));
|
||||
}
|
||||
|
||||
~CallbackConfirmation() { g_weak_ref_clear(&window); }
|
||||
|
||||
GWeakRef window;
|
||||
CaptchaEventType type = CaptchaEventType::kShown;
|
||||
};
|
||||
|
||||
@@ -612,16 +606,6 @@ void FinishCallbackConfirmation(GObject* source_object, GAsyncResult* result,
|
||||
if (value != nullptr) {
|
||||
g_object_unref(value);
|
||||
}
|
||||
|
||||
auto* window_object =
|
||||
static_cast<GObject*>(g_weak_ref_get(&confirmation->window));
|
||||
if (confirmed && confirmation->type == CaptchaEventType::kSuccess &&
|
||||
window_object != nullptr) {
|
||||
gtk_window_close(GTK_WINDOW(window_object));
|
||||
}
|
||||
if (window_object != nullptr) {
|
||||
g_object_unref(window_object);
|
||||
}
|
||||
delete confirmation;
|
||||
}
|
||||
|
||||
@@ -644,9 +628,8 @@ void HandleHybridCommand(WebKitUserContentManager* manager,
|
||||
if (window_object == nullptr) {
|
||||
return;
|
||||
}
|
||||
auto* confirmation =
|
||||
new CallbackConfirmation(GTK_WINDOW(window_object), event.type);
|
||||
g_object_unref(window_object);
|
||||
auto* confirmation = new CallbackConfirmation{event.type};
|
||||
const std::string script = BuildCallbackScript(event.callback_id);
|
||||
WebKitWebView* web_view = WEBKIT_WEB_VIEW(
|
||||
g_object_get_data(G_OBJECT(manager), "mocktail-web-view"));
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "webview/roblox_challenge_features.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace webview {
|
||||
namespace {
|
||||
@@ -306,8 +308,7 @@ bool ParseCaptchaEvent(std::string_view command, CaptchaEvent* event) {
|
||||
event->type = CaptchaEventType::kShown;
|
||||
return true;
|
||||
}
|
||||
if (feature == "CaptchaSuccess" || feature == "challengeCompleted" ||
|
||||
feature == "ChallengeCompleted") {
|
||||
if (IsRobloxChallengeSolvedFeature(feature)) {
|
||||
event->type = CaptchaEventType::kSuccess;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -722,6 +722,15 @@ target_link_libraries(elf_build_id_test PRIVATE
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
add_executable(web_view_protocol_contract_test
|
||||
web_view_protocol_contract_test.cc
|
||||
)
|
||||
target_link_libraries(web_view_protocol_contract_test PRIVATE
|
||||
Mocktail::Compat
|
||||
GTest::gtest_main
|
||||
)
|
||||
mocktail_apply_compile_options(web_view_protocol_contract_test)
|
||||
|
||||
add_executable(build_profile_test build_profile_test.cc)
|
||||
target_link_libraries(build_profile_test PRIVATE
|
||||
Mocktail::Compat
|
||||
@@ -1049,6 +1058,7 @@ gtest_discover_tests(auth_service_test)
|
||||
gtest_discover_tests(auth_runtime_composition_test)
|
||||
gtest_discover_tests(client_settings_service_test)
|
||||
gtest_discover_tests(elf_build_id_test)
|
||||
gtest_discover_tests(web_view_protocol_contract_test)
|
||||
gtest_discover_tests(build_profile_test)
|
||||
gtest_discover_tests(host_abi_profile_test)
|
||||
gtest_discover_tests(payload_compatibility_host_abi_gate_test)
|
||||
|
||||
@@ -1225,6 +1225,61 @@ while True:
|
||||
uint64_t process_generation) {
|
||||
composition->HandleWebSurfaceExit(process_generation);
|
||||
}
|
||||
|
||||
static void MarkClosesWhenChallengeSolved(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
composition->web_surface_close_when_challenge_solved_ = true;
|
||||
}
|
||||
|
||||
static bool SolvedChallengeClosePending(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
return composition->solved_challenge_close_deadline_.has_value();
|
||||
}
|
||||
|
||||
static void ExpireSolvedChallengeGrace(
|
||||
RobloxExperienceComposition* composition) {
|
||||
std::lock_guard<std::mutex> lock(composition->mutex_);
|
||||
if (composition->solved_challenge_close_deadline_.has_value()) {
|
||||
composition->solved_challenge_close_deadline_ =
|
||||
std::chrono::steady_clock::now();
|
||||
}
|
||||
}
|
||||
|
||||
static std::string ChallengeSuccess() {
|
||||
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"data":{"captchaData":{}},)"
|
||||
R"("feature":"CaptchaSuccess"}},"callbackID":"challenge-1"})";
|
||||
}
|
||||
|
||||
static std::unique_ptr<RobloxExperienceComposition> MakeWebViewComposition(
|
||||
jnivm::VM* vm, Probe* probe) {
|
||||
for (const char* class_name : {
|
||||
"com/roblox/protocols/webview/WebViewProtocol",
|
||||
"com/roblox/universalapp/messagebus/MessageBus",
|
||||
"com/roblox/universalapp/messagebus/Connection",
|
||||
"com/roblox/engine/jni/memstorage/MemStorage",
|
||||
"com/roblox/engine/jni/memstorage/Connection",
|
||||
"com/roblox/engine/jni/memstorage/Callback",
|
||||
}) {
|
||||
vm->RegisterClass(class_name);
|
||||
}
|
||||
return std::make_unique<RobloxExperienceComposition>(
|
||||
JniEnvironmentProvider{vm->GetJavaVM(), vm, Prepare},
|
||||
RobloxExperienceMessageBusSymbols{},
|
||||
RobloxWebViewMessageBusSymbols{
|
||||
GetWebViewOpenId, GetWebViewHandleWindowCloseId,
|
||||
GetWebViewProtocolName, GetWebViewAvailabilityId,
|
||||
GetWebViewMessageId, InitializeWebViewProtocol, Subscribe,
|
||||
Disconnect, SetRequestHandler, ClearNativeRequestHandler,
|
||||
PublishRaw, BroadcastDataModelFocus, GetWebViewMutateId,
|
||||
GetWebViewCloseId, SignalWebViewJavascriptCallback,
|
||||
UpdateCookieSetHandler},
|
||||
BrowserServiceSymbols(), PermissionsSymbols(),
|
||||
RobloxGameSessionSymbols{}, JniFactory(probe),
|
||||
RobloxFreshLaunchPresentBoundary{});
|
||||
}
|
||||
};
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
@@ -1412,6 +1467,123 @@ TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
<< status.message();
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ForwardsSolvedChallengeAndLeavesClosingToRoblox) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
success})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_TRUE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ASSERT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedChallengeWhenRobloxLeavesItOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kExecuteRoblox,
|
||||
ChallengeSuccess()})
|
||||
.ok());
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
ExpireSolvedChallengeGrace(composition.get());
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = composition->DrainPlatformEvents();
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_NE(log.find("closing solved challenge window"), std::string::npos)
|
||||
<< log;
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
ClosesSolvedCaptchaWindowAfterForwardingSuccess) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
MarkClosesWhenChallengeSolved(composition.get());
|
||||
|
||||
const std::string success = ChallengeSuccess();
|
||||
::testing::internal::CaptureStderr();
|
||||
const Status status = RouteCurrentEvent(
|
||||
composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid, success});
|
||||
const std::string log = ::testing::internal::GetCapturedStderr();
|
||||
ASSERT_TRUE(status.ok()) << status.message();
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, success);
|
||||
EXPECT_EQ(exit_probe->calls, 1);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
EXPECT_NE(log.find("closing solved captcha window"), std::string::npos)
|
||||
<< log;
|
||||
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST_F(RobloxExperienceCompositionWebSurfaceTest,
|
||||
UnsolvedChallengeEventsKeepWindowOpen) {
|
||||
jnivm::VM vm;
|
||||
Probe probe{&vm};
|
||||
g_probe = &probe;
|
||||
auto composition = MakeWebViewComposition(&vm, &probe);
|
||||
ASSERT_TRUE(composition->InitializePlatformProtocols().ok());
|
||||
auto exit_probe = std::make_shared<ExitProbe>();
|
||||
Activate(composition.get(), false, 17, 23, exit_probe);
|
||||
|
||||
const std::string shown =
|
||||
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"feature":"CaptchaShown"}},"callbackID":"c"})";
|
||||
ASSERT_TRUE(RouteCurrentEvent(composition.get(), 17, 23,
|
||||
{WebViewHelperEventType::kRobloxWkHybrid,
|
||||
shown})
|
||||
.ok());
|
||||
EXPECT_EQ(probe.web_view_javascript_callback, shown);
|
||||
EXPECT_FALSE(SolvedChallengeClosePending(composition.get()));
|
||||
ASSERT_TRUE(composition->DrainPlatformEvents().ok());
|
||||
EXPECT_EQ(exit_probe->calls, 0);
|
||||
|
||||
EXPECT_TRUE(Close(composition.get()).ok());
|
||||
EXPECT_TRUE(composition->Shutdown().ok());
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
namespace {} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -162,6 +162,13 @@ void InitializeAndroidWebViewProtocol(JNIEnv *, jclass) {
|
||||
g_web_view_probe->initialization_order.push_back("initialize");
|
||||
}
|
||||
|
||||
int g_fake_protocol_holder = 0;
|
||||
|
||||
RobloxWebViewProtocolHandle AcquireWebViewProtocol() {
|
||||
g_web_view_probe->initialization_order.push_back("acquire_protocol");
|
||||
return RobloxWebViewProtocolHandle(&g_fake_protocol_holder);
|
||||
}
|
||||
|
||||
void SignalJavascriptCallback(JNIEnv* env, jclass, jstring payload) {
|
||||
g_web_view_probe->hybrid_callback_payload = ReadJavaString(env, payload);
|
||||
}
|
||||
@@ -473,6 +480,34 @@ TEST(RobloxWebViewParserTest, BuildsExactCaptchaRoutesWithoutGuessingSuccess) {
|
||||
.ok());
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewParserTest, RecognizesOnlyChallengeSuccessNavigation) {
|
||||
const auto navigation = [](const std::string& feature) {
|
||||
return R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"data":{"captchaData":{}},"feature":")" +
|
||||
feature + R"("}},"callbackID":"challenge-1"})";
|
||||
};
|
||||
EXPECT_TRUE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaSuccess")));
|
||||
EXPECT_TRUE(
|
||||
IsRobloxWebViewChallengeSuccess(navigation("challengeCompleted")));
|
||||
EXPECT_TRUE(
|
||||
IsRobloxWebViewChallengeSuccess(navigation("ChallengeCompleted")));
|
||||
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(navigation("CaptchaShown")));
|
||||
EXPECT_FALSE(
|
||||
IsRobloxWebViewChallengeSuccess(navigation("ChallengeDisplayed")));
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
|
||||
R"({"moduleID":"Game","functionName":"navigateToFeature",)"
|
||||
R"("params":{"params":{"feature":"CaptchaSuccess"}}})"));
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
|
||||
R"({"moduleID":"Navigation","functionName":"navigateToFeature",)"
|
||||
R"("params":{"feature":"CaptchaSuccess"}})"));
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(
|
||||
R"({"moduleID":7,"functionName":["navigateToFeature"],)"
|
||||
R"("params":{"params":{"feature":{"CaptchaSuccess":true}}}})"));
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess("CaptchaSuccess"));
|
||||
EXPECT_FALSE(IsRobloxWebViewChallengeSuccess(""));
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewParserTest, AcceptsOnlyCanonicalProfileRoutes) {
|
||||
RobloxWebViewOpenRequest request;
|
||||
Status status = ParseRobloxProfileViewUrl(
|
||||
@@ -656,6 +691,7 @@ TEST(RobloxWebViewBridgeTest,
|
||||
ASSERT_EQ(probe.dispatches, 2);
|
||||
EXPECT_EQ(probe.request.url,
|
||||
"https://www.roblox.com/games/servers-section/10148749921");
|
||||
EXPECT_FALSE(probe.request.close_when_challenge_solved);
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
EXPECT_EQ(probe.data_model_focus_states,
|
||||
(std::vector<std::string>{"Unfocused"}));
|
||||
@@ -677,6 +713,7 @@ TEST(RobloxWebViewBridgeTest,
|
||||
ASSERT_EQ(probe.dispatches, 3);
|
||||
EXPECT_EQ(probe.request.url, "https://www.roblox.com/login");
|
||||
EXPECT_EQ(probe.request.title, "Roblox sign in");
|
||||
EXPECT_TRUE(probe.request.close_when_challenge_solved);
|
||||
EXPECT_TRUE(bridge.HandleCloseWindow().ok());
|
||||
EXPECT_EQ(probe.close_dispatches, 1);
|
||||
ASSERT_TRUE(bridge.DrainHostWindowEvents().ok());
|
||||
@@ -1218,6 +1255,45 @@ TEST(RobloxWebViewBridgeTest,
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
TEST(RobloxWebViewBridgeTest, HoldsWebViewProtocolFromBeforeNativeInitialization) {
|
||||
const ScopedNativeLogin native_login(false);
|
||||
jnivm::VM vm;
|
||||
JNIEnv* env = vm.GetJNIEnv();
|
||||
jclass bus_class =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
jobject bus = env->AllocObject(bus_class);
|
||||
WebViewBridgeProbe probe;
|
||||
g_web_view_probe = &probe;
|
||||
{
|
||||
RobloxWebViewBridge bridge(
|
||||
{vm.GetJavaVM(), nullptr, nullptr},
|
||||
{&GetOpenWindowId, &GetHandleWindowCloseId, &GetProtocolName,
|
||||
&GetIsAvailableId, &GetMessageId, &InitializeAndroidWebViewProtocol,
|
||||
&SubscribeRaw, &DeleteConnection, &SetRequestHandler,
|
||||
&ClearRequestHandler, &PublishRaw, &BroadcastDataModelFocus,
|
||||
&GetMutateWindowId, &GetCloseWindowId, &SignalJavascriptCallback,
|
||||
&UpdateCookieSetHandler, &AcquireWebViewProtocol},
|
||||
{bus, &vm, &CreateRawCallback, &ClearRawCallback, &CreateRequestHandler,
|
||||
&ClearRequestHandlerObject, &SetPlatformWebCallbacks,
|
||||
&ClearPlatformWebCallbacks},
|
||||
{&probe, &DispatchOpen, &DispatchMutate, &DispatchClose,
|
||||
&DispatchCookie});
|
||||
|
||||
ASSERT_TRUE(bridge.Initialize().ok());
|
||||
ASSERT_TRUE(bridge.Shutdown().ok());
|
||||
}
|
||||
|
||||
// Roblox's own app start keeps this handle for the process lifetime.
|
||||
// Releasing it deletes the protocol and its handleJavascriptCallback
|
||||
// subscriber.
|
||||
EXPECT_EQ(probe.initialization_order,
|
||||
(std::vector<std::string>{
|
||||
"message_id:openWindow", "message_id:mutateWindow",
|
||||
"message_id:closeWindow", "availability", "subscribe",
|
||||
"subscribe", "subscribe", "acquire_protocol", "initialize"}));
|
||||
g_web_view_probe = nullptr;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -0,0 +1,198 @@
|
||||
#include "compat/web_view_protocol_contract.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail::compat {
|
||||
namespace {
|
||||
|
||||
std::string FromHex(std::string_view hex) {
|
||||
std::string bytes;
|
||||
for (std::size_t index = 0; index + 1 < hex.size(); index += 2) {
|
||||
bytes += static_cast<char>(
|
||||
std::stoi(std::string(hex.substr(index, 2)), nullptr, 16));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
std::string Flipped(std::string code, std::size_t index) {
|
||||
code[index] = static_cast<char>(code[index] ^ 0x01);
|
||||
return code;
|
||||
}
|
||||
|
||||
// Start of initializeAndroidWebViewProtocol and the getter it calls first,
|
||||
// from the x86_64 and arm64-v8a libroblox.so of each named Roblox release.
|
||||
struct KnownBuild {
|
||||
const char* name;
|
||||
std::uintptr_t initializer;
|
||||
const char* initializer_code;
|
||||
std::uintptr_t getter;
|
||||
const char* getter_code;
|
||||
};
|
||||
|
||||
constexpr KnownBuild kX86_64Builds[] = {
|
||||
{"2.734.917", 0x2e7418f,
|
||||
"554889e54157415641554154534883ec384c8b2599ce1004498b0424488945d0488d5da8"
|
||||
"4889dfe8c9a355ff488b1b488b43184c",
|
||||
0x23ce584,
|
||||
"554889e553504889fb8a05ede9c10484c07419488d3dbae9c104e8590000004889034889"
|
||||
"d84883c4085b5dc3"},
|
||||
{"2.736.1408", 0x2e9d241,
|
||||
"554889e54157415641554154534883ec484c8b25df291c04498b0424488945d0488d5d98"
|
||||
"4889dfe8af2d56ff488b1b488b43184c",
|
||||
0x240001c,
|
||||
"554889e553504889fb8a05a5b9cc0484c07419488d3d72b9cc04e8590000004889034889"
|
||||
"d84883c4085b5dc3"},
|
||||
{"2.738.1397", 0x2e6c591,
|
||||
"554889e54157415641554154534883ec184c8b2577d52704498b0424488945d0488d5dc0"
|
||||
"4889dfe8fbf55cff488b1b488b43184c",
|
||||
0x243bbb8,
|
||||
"554889e553504889fb8a05f99dd10484c07419488d3dc69dd104e8590000004889034889"
|
||||
"d84883c4085b5dc3"},
|
||||
};
|
||||
|
||||
constexpr KnownBuild kArm64Builds[] = {
|
||||
{"2.738.1397", 0x2bac4bc,
|
||||
"ffc301d1fd7b02a9f91b00f9f85f04a9f65705a9f44f06a9fd83009137e101b0f77a43f9"
|
||||
"e80240f9a8831ff8e8430091e82300915244de97f30740f9680e40f9",
|
||||
0x233d638,
|
||||
"fd7bbea9f30b00f9fd030091e92702f029e13391f30308aa29fddf0809010036e02702f0"
|
||||
"0040339117000094600200f9f30b40f9fd7bc2a8c0035fd6"},
|
||||
{"2.740.931", 0x2be87d8,
|
||||
"ff0302d1fd7b03a9f92300f9f85f05a9f65706a9f44f07a9fdc3009157e801d0f7b244f9"
|
||||
"e80240f9a8831ff8e84b0091e82300913a64de97f30740f9680e40f9",
|
||||
0x23818f4,
|
||||
"fd7bbea9f30b00f9fd030091492f02d029212a91f30308aa29fddf0809010036402f02d0"
|
||||
"0080299117000094600200f9f30b40f9fd7bc2a8c0035fd6"},
|
||||
};
|
||||
|
||||
TEST(WebViewProtocolContractTest, FindsX86_64GetterInKnownBuilds) {
|
||||
for (const KnownBuild& build : kX86_64Builds) {
|
||||
EXPECT_EQ(FindWebViewProtocolGetterCallX86_64(
|
||||
FromHex(build.initializer_code), build.initializer),
|
||||
std::optional<std::uintptr_t>(build.getter))
|
||||
<< build.name;
|
||||
const std::string getter = FromHex(build.getter_code);
|
||||
ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesX86_64) << build.name;
|
||||
EXPECT_TRUE(HasWebViewProtocolGetterContractX86_64(getter)) << build.name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsChangedX86_64CallSite) {
|
||||
const KnownBuild& build = kX86_64Builds[2];
|
||||
const std::string code = FromHex(build.initializer_code);
|
||||
// Frame setup, the handle slot's lea, the handle argument, the call, the
|
||||
// handle load and the protocol field offset.
|
||||
for (const std::size_t fixed : {0u, 3u, 32u, 34u, 36u, 39u, 44u, 50u}) {
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(Flipped(code, fixed),
|
||||
build.initializer)
|
||||
.has_value())
|
||||
<< fixed;
|
||||
}
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code.substr(0, 50),
|
||||
build.initializer)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsAmbiguousX86_64CallSite) {
|
||||
const KnownBuild& build = kX86_64Builds[2];
|
||||
const std::string code = FromHex(build.initializer_code);
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(code + code.substr(32, 19),
|
||||
build.initializer)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsChangedX86_64Getter) {
|
||||
const std::string getter = FromHex(kX86_64Builds[2].getter_code);
|
||||
// Frame setup, the out-pointer save, the guard test, the holder lea, the
|
||||
// local acquire call, the out-pointer store and the return.
|
||||
for (const std::size_t fixed : {0u, 8u, 16u, 21u, 27u, 33u, 43u}) {
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(Flipped(getter, fixed)))
|
||||
<< fixed;
|
||||
}
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(
|
||||
getter.substr(0, getter.size() - 1)));
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, FindsArm64GetterInKnownBuilds) {
|
||||
for (const KnownBuild& build : kArm64Builds) {
|
||||
EXPECT_EQ(FindWebViewProtocolGetterCallArm64(
|
||||
FromHex(build.initializer_code), build.initializer),
|
||||
std::optional<std::uintptr_t>(build.getter))
|
||||
<< build.name;
|
||||
const std::string getter = FromHex(build.getter_code);
|
||||
ASSERT_EQ(getter.size(), kWebViewProtocolGetterBytesArm64) << build.name;
|
||||
EXPECT_TRUE(HasWebViewProtocolGetterContractArm64(getter)) << build.name;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsChangedArm64CallSite) {
|
||||
const KnownBuild& build = kArm64Builds[0];
|
||||
const std::string code = FromHex(build.initializer_code);
|
||||
// Words 12..15: add x8, sp, #8; bl getter; ldr x19, [sp, #8];
|
||||
// ldr x8, [x19, #0x18]. Byte 0 of each word holds its low register field.
|
||||
EXPECT_FALSE(
|
||||
FindWebViewProtocolGetterCallArm64(Flipped(code, 48), build.initializer)
|
||||
.has_value());
|
||||
std::string not_a_call = code;
|
||||
not_a_call[55] = static_cast<char>(not_a_call[55] ^ 0x80); // bl -> b
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(not_a_call, build.initializer)
|
||||
.has_value());
|
||||
EXPECT_FALSE(
|
||||
FindWebViewProtocolGetterCallArm64(Flipped(code, 56), build.initializer)
|
||||
.has_value());
|
||||
EXPECT_FALSE(
|
||||
FindWebViewProtocolGetterCallArm64(Flipped(code, 60), build.initializer)
|
||||
.has_value());
|
||||
// The handle is read back from a different stack slot than the one passed.
|
||||
std::string other_slot = code;
|
||||
other_slot[57] = static_cast<char>(other_slot[57] ^ 0x04);
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(other_slot, build.initializer)
|
||||
.has_value());
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code.substr(0, 60),
|
||||
build.initializer)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsAmbiguousArm64CallSite) {
|
||||
const KnownBuild& build = kArm64Builds[0];
|
||||
const std::string code = FromHex(build.initializer_code);
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(code + code.substr(48, 16),
|
||||
build.initializer)
|
||||
.has_value());
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsChangedArm64Getter) {
|
||||
const std::string getter = FromHex(kArm64Builds[0].getter_code);
|
||||
// Frame setup, mov x19, x8 (the indirect result register), the guard
|
||||
// branch, the local acquire call, the result store and the return.
|
||||
for (const std::size_t fixed : {0u, 20u, 28u, 40u, 44u, 56u}) {
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(Flipped(getter, fixed)))
|
||||
<< fixed;
|
||||
}
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(
|
||||
getter.substr(0, getter.size() - 1)));
|
||||
}
|
||||
|
||||
TEST(WebViewProtocolContractTest, RejectsOtherArchitectureCode) {
|
||||
const KnownBuild& x86_64 = kX86_64Builds[2];
|
||||
const KnownBuild& arm64 = kArm64Builds[0];
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallArm64(
|
||||
FromHex(x86_64.initializer_code), x86_64.initializer)
|
||||
.has_value());
|
||||
EXPECT_FALSE(FindWebViewProtocolGetterCallX86_64(
|
||||
FromHex(arm64.initializer_code), arm64.initializer)
|
||||
.has_value());
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractArm64(
|
||||
FromHex(x86_64.getter_code) + std::string(16, '\0')));
|
||||
EXPECT_FALSE(HasWebViewProtocolGetterContractX86_64(
|
||||
FromHex(arm64.getter_code)));
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::compat
|
||||
Reference in new issue
Block a user