mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-17 18:02:19 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
169e65bf60 | ||
|
|
69476f0f47 | ||
|
|
79d0cdfb40 | ||
|
|
273209b9ea | ||
|
|
bbfd603e17 | ||
|
|
1ba2941e7f | ||
|
|
68c90ce428 | ||
|
|
068f07e125 | ||
|
|
a469689bbb | ||
|
|
51836a980d | ||
|
|
ad546a7781 | ||
|
|
3c45450dfc | ||
|
|
185c11b57f | ||
|
|
2c3d7b485e | ||
|
|
6f8344a28b | ||
|
|
b60c43e8f0 | ||
|
|
b5e4273277 | ||
|
|
d2858fedc8 | ||
|
|
326a0659ea | ||
|
|
dd45467056 | ||
|
|
94b1036296 | ||
|
|
4c61daad0c | ||
|
|
f5f224875a | ||
|
|
90d4891a4e | ||
|
|
46dd99266c | ||
|
|
77fce18a95 | ||
|
|
d6f0f2c755 | ||
|
|
c6afc5af24 | ||
|
|
74e7455ee1 | ||
|
|
000d021452 | ||
|
|
2eef2c3821 | ||
|
|
4928443f90 | ||
|
|
b37497db7b | ||
|
|
6bbdc39d7e | ||
|
|
1c11336ba1 |
No files matched your search
@@ -104,14 +104,25 @@ jobs:
|
||||
nlohmann-json3-dev libyaml-dev libelf-dev libminizip-dev \
|
||||
libcapstone-dev libgtk-4-dev libadwaita-1-dev libwebkitgtk-6.0-dev \
|
||||
libutf8proc-dev libfontconfig1-dev libegl-dev libvulkan-dev \
|
||||
libplacebo-dev libpng-dev zlib1g-dev
|
||||
libplacebo-dev libpng-dev zlib1g-dev \
|
||||
xvfb xauth libgl1-mesa-dri jq patchelf
|
||||
|
||||
- name: Build and check native executables
|
||||
shell: bash
|
||||
run: |
|
||||
test "$(uname -m)" = "${{ matrix.arch }}"
|
||||
cmake -S . -B build-ci -G Ninja \
|
||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF
|
||||
-DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
|
||||
cmake --build build-ci --parallel 4
|
||||
./build-ci/mocktail --help
|
||||
./build-ci/mocktail_updater status
|
||||
|
||||
- name: Run C++ and integration tests
|
||||
shell: bash
|
||||
env:
|
||||
SDL_AUDIODRIVER: dummy
|
||||
SDL_VIDEODRIVER: x11
|
||||
LIBGL_ALWAYS_SOFTWARE: '1'
|
||||
run: |
|
||||
xvfb-run -a ctest --test-dir build-ci --output-on-failure \
|
||||
--parallel 4 --timeout 120
|
||||
+44
-1
@@ -6,6 +6,48 @@ GitHub are taken from the matching section here.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.3] - 2026-10-04
|
||||
|
||||
### Added
|
||||
|
||||
- Joining a friend from a Roblox "follow user" link now works. Nightcap looks
|
||||
up which game they are in and joins it.
|
||||
- Nix users get a desktop entry for the app.
|
||||
|
||||
### Changed
|
||||
|
||||
- Roblox textures now decode about 3 times faster, which shortens the stalls
|
||||
when a place loads and when new parts of the map stream in. Thanks to
|
||||
@glook9001.
|
||||
|
||||
### Fixed
|
||||
|
||||
- `make update-roblox` no longer deletes your previous Roblox install when an
|
||||
import fails partway through. If a step fails, the old files are put back,
|
||||
or kept in a folder whose path is printed. Thanks to @kimlherme.
|
||||
- A failed Roblox update now restores your previous version's records instead
|
||||
of leaving them half switched. A damaged version file is rejected instead of
|
||||
being half read, and a temporary failure to start the startup check no longer
|
||||
marks a working Roblox build as broken for good. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when Roblox clears its audio queue while sound is
|
||||
still playing, and a memory leak in lists of objects passed to Roblox's Java
|
||||
code. Thanks to @kimlherme.
|
||||
- Fixed a possible crash when the text overlay shuts down, a freeze when Roblox
|
||||
releases the mouse lock while another release is already in progress, and
|
||||
network lookups that misread Roblox's options. Thanks to @kimlherme.
|
||||
- Fixed the sky sometimes rendering with the wrong textures in the Vulkan
|
||||
renderer.
|
||||
- Clicking while a Roblox text box has focus no longer closes it. Only Roblox
|
||||
decides when a click ends typing.
|
||||
- Going to another page in the web view, such as Friends from a profile, no
|
||||
longer closes the web view. It crashed on every page change on Wayland
|
||||
systems whose GL setup GTK could not use.
|
||||
- Fixed a crash when Roblox's saved dark or light theme setting was missing or
|
||||
damaged.
|
||||
- The Flatpak build now detects an NVIDIA driver from inside its sandbox, so it
|
||||
uses X11 instead of native Wayland where both are available. This avoids
|
||||
Wayland hangs and display disconnects.
|
||||
|
||||
## [0.3.2] - 2026-09-29
|
||||
|
||||
### Added
|
||||
@@ -238,7 +280,8 @@ First Nightcap release, forked from komaruworld/mocktail.
|
||||
- The AppImage build no longer fails on stale checksums for the slimmed
|
||||
Arch packages.
|
||||
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.2...HEAD
|
||||
[Unreleased]: https://github.com/CoderDayton/nightcap/compare/v0.3.3...HEAD
|
||||
[0.3.3]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.3
|
||||
[0.3.2]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.2
|
||||
[0.3.1]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.1
|
||||
[0.3.0]: https://github.com/CoderDayton/nightcap/releases/tag/v0.3.0
|
||||
|
||||
+7
-1
@@ -249,6 +249,9 @@ mocktail_apply_compile_options(mocktail_updater)
|
||||
|
||||
add_library(mocktail_legacy_jnivm STATIC
|
||||
src/jnivm/jnivm.cc
|
||||
src/jnivm/jni_references.cc
|
||||
src/jnivm/jni_fields.cc
|
||||
src/jnivm/jni_strings.cc
|
||||
)
|
||||
add_library(Mocktail::LegacyJni ALIAS mocktail_legacy_jnivm)
|
||||
target_include_directories(mocktail_legacy_jnivm PUBLIC include)
|
||||
@@ -285,10 +288,13 @@ add_library(mocktail_runtime STATIC
|
||||
src/runtime/roblox_capability_resolver.cc
|
||||
src/runtime/roblox_experience_launch_bridge.cc
|
||||
src/runtime/roblox_launch_uri.cc
|
||||
src/runtime/roblox_presence_resolver.cc
|
||||
src/runtime/roblox_game_session_native_adapter.cc
|
||||
src/runtime/roblox_web_view_bridge.cc
|
||||
src/runtime/roblox_permissions_bridge.cc
|
||||
src/runtime/roblox_account_bridge.cc
|
||||
src/runtime/roblox_message_bus_request_bridge.cc
|
||||
src/runtime/roblox_call_protocol_bridge.cc
|
||||
src/runtime/payload_update_preflight.cc
|
||||
src/runtime/process_launch_policy.cc
|
||||
src/runtime/process_diagnostics.cc
|
||||
@@ -474,7 +480,7 @@ target_link_libraries(mocktail_window PUBLIC
|
||||
Mocktail::SdlVulkanWsi
|
||||
SDL3::SDL3
|
||||
${CMAKE_DL_LIBS}
|
||||
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay
|
||||
PRIVATE Mocktail::Runtime Mocktail::GlesTextOverlay Mocktail::GraphicsFoundation
|
||||
)
|
||||
|
||||
add_library(mocktail_roblox_output_device_runtime STATIC
|
||||
|
||||
@@ -95,6 +95,12 @@ The APK is checked before any native code is loaded. It is downloaded on first
|
||||
launch and is not bundled with Nightcap. The last working copy is kept in case
|
||||
an update fails.
|
||||
|
||||
A leftover `.previous_good-recovery.json` in the payload store marks pending
|
||||
recovery after a publication or restoration failure and blocks further payload
|
||||
publication and garbage collection. Check `current.json` and `previous_good.json`
|
||||
against the saved recovery state and fix the underlying problem before manually
|
||||
removing the marker.
|
||||
|
||||
<details>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
|
||||
@@ -49,6 +49,17 @@
|
||||
gdb
|
||||
];
|
||||
|
||||
desktopItems = [
|
||||
(pkgs.makeDesktopItem {
|
||||
name = "Mocktail";
|
||||
exec = "mocktail";
|
||||
icon = "mocktail";
|
||||
desktopName = "Mocktail";
|
||||
comment = "Experimental Roblox compatibility runtime for Linux and FreeBSD";
|
||||
categories = [ "Game" ];
|
||||
})
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${pkgs.lib.makeLibraryPath package.buildInputs}"
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ struct AngleProbeOptions {
|
||||
bool allow_software_device = false;
|
||||
};
|
||||
|
||||
// Checks ANGLE entry points and client extensions without initializing a display.
|
||||
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options);
|
||||
|
||||
// Loads a pinned ANGLE pair, verifies the required EGL/GLES symbols, and
|
||||
// initializes an EGL_PLATFORM_ANGLE Vulkan display. No context is fabricated.
|
||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options);
|
||||
|
||||
@@ -35,6 +35,10 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::uint32_t width,
|
||||
std::uint32_t height, std::uint8_t* destination);
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::size_t source_stride_bytes,
|
||||
std::uint32_t width, std::uint32_t height,
|
||||
std::uint8_t* destination);
|
||||
|
||||
// Replacement textures keyed by the hash of a texture's level-0 compressed
|
||||
// bytes. An override lives at <override_dir>/<hash>.png. With a dump
|
||||
|
||||
@@ -47,8 +47,8 @@ struct RobloxThemeCacheResult {
|
||||
};
|
||||
|
||||
// Reads the current account's Roblox theme without changing appStorage. A
|
||||
// missing value leaves dark_theme empty so the caller can use Roblox's own
|
||||
// startup default.
|
||||
// missing or unrecognized value falls back to AuthenticatedTheme, then the
|
||||
// caller's startup default.
|
||||
RobloxThemeCacheResult ReadRobloxThemeCache(
|
||||
const std::filesystem::path& app_storage_file,
|
||||
std::int64_t authenticated_user_id);
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_CALL_PROTOCOL_BRIDGE_H_
|
||||
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
// In-experience CoreScripts query the app's cross-experience call state before
|
||||
// initializing voice. Mocktail does not start or accept cross-experience calls,
|
||||
// so that state is Idle. This query neither joins voice nor opens a microphone.
|
||||
class RobloxCallProtocolBridge final {
|
||||
public:
|
||||
RobloxCallProtocolBridge(JniEnvironmentProvider environment,
|
||||
RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects);
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
RobloxMessageBusRequestBridge bridge_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
#endif
|
||||
@@ -15,6 +15,7 @@
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/owned_pthread.h"
|
||||
#include "runtime/roblox_browser_service_bridge.h"
|
||||
#include "runtime/roblox_call_protocol_bridge.h"
|
||||
#include "runtime/roblox_experience_launch_bridge.h"
|
||||
#include "runtime/roblox_experience_presence.h"
|
||||
#include "runtime/roblox_fresh_game_launch_controller.h"
|
||||
@@ -97,7 +98,9 @@ struct RobloxExperienceSurfaceProvider {
|
||||
void* context = nullptr;
|
||||
GameSurface (*snapshot)(void* context) = nullptr;
|
||||
|
||||
bool valid() const { return snapshot != nullptr; }
|
||||
bool valid() const {
|
||||
return snapshot != nullptr;
|
||||
}
|
||||
};
|
||||
|
||||
// Production composition for the APK platform protocols and ExperienceProtocol
|
||||
@@ -256,6 +259,7 @@ class RobloxExperienceComposition final {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge_;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge_;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge_;
|
||||
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge_;
|
||||
// Optional: a payload without the AccountProtocol entrypoints still starts.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge_;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process_;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_MESSAGE_BUS_REQUEST_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
struct RobloxMessageBusSymbols {
|
||||
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||
jboolean) = nullptr;
|
||||
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||
jstring) = nullptr;
|
||||
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return subscribe_request && delete_connection && publish_response &&
|
||||
set_async_handler && clear_handler && call_response_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxMessageBusObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_raw_callback &&
|
||||
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxMessageBusReply {
|
||||
std::string json;
|
||||
jint code = 0;
|
||||
const char* outcome = "ok";
|
||||
};
|
||||
|
||||
// Owns both legacy subscriptions and correlated async request handlers.
|
||||
// A policy receives only a request's method index and JNI-local payload.
|
||||
class RobloxMessageBusRequestBridge final {
|
||||
public:
|
||||
using Handler =
|
||||
std::function<RobloxMessageBusReply(JNIEnv*, jstring, std::size_t)>;
|
||||
RobloxMessageBusRequestBridge(JniEnvironmentProvider environment,
|
||||
RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects,
|
||||
std::string protocol,
|
||||
std::vector<std::string> methods,
|
||||
Handler handler);
|
||||
~RobloxMessageBusRequestBridge();
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxMessageBusSymbols symbols_;
|
||||
const RobloxMessageBusObjects objects_;
|
||||
const std::string protocol_name_;
|
||||
const std::vector<std::string> methods_;
|
||||
const Handler handler_;
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
};
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
#endif
|
||||
@@ -1,58 +1,12 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
#include "runtime/roblox_game_session_native_adapter.h"
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
using RobloxPermissionsMessageBusSymbols = RobloxMessageBusSymbols;
|
||||
using RobloxPermissionsMessageBusObjects = RobloxMessageBusObjects;
|
||||
|
||||
// Exact MessageBus JNI entrypoints used by the APK PermissionsProtocol.
|
||||
struct RobloxPermissionsMessageBusSymbols {
|
||||
jobject (*subscribe_request)(JNIEnv*, jobject, jstring, jstring, jobject,
|
||||
jboolean) = nullptr;
|
||||
void (*delete_connection)(JNIEnv*, jobject, jlong) = nullptr;
|
||||
void (*publish_response)(JNIEnv*, jobject, jstring, jstring, jstring, jint,
|
||||
jstring) = nullptr;
|
||||
void (*set_async_handler)(JNIEnv*, jobject, jstring, jstring,
|
||||
jobject) = nullptr;
|
||||
void (*clear_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
void (*call_response_handler)(JNIEnv*, jobject, jstring, jstring) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return subscribe_request && delete_connection && publish_response &&
|
||||
set_async_handler && clear_handler && call_response_handler;
|
||||
}
|
||||
};
|
||||
|
||||
struct RobloxPermissionsMessageBusObjects {
|
||||
jobject message_bus = nullptr;
|
||||
void* context = nullptr;
|
||||
jobject (*create_raw_callback)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring)) = nullptr;
|
||||
void (*clear_raw_callback)(void*, jobject) = nullptr;
|
||||
jobject (*create_async_handler)(void*, std::shared_ptr<void>,
|
||||
void (*)(void*, JNIEnv*, jstring,
|
||||
jstring)) = nullptr;
|
||||
void (*clear_async_handler)(void*, jobject) = nullptr;
|
||||
|
||||
bool complete() const {
|
||||
return message_bus && context && create_raw_callback &&
|
||||
clear_raw_callback && create_async_handler && clear_async_handler;
|
||||
}
|
||||
};
|
||||
|
||||
// Installs both legacy protocol subscriptions and correlated async handlers
|
||||
// before native bootstrap. MICROPHONE_ACCESS follows host capture
|
||||
// configuration; LOCAL_NETWORK needs no extra runtime permission, matching the
|
||||
// Android APK. No query/request opens an audio device, enables a disabled
|
||||
// microphone, or changes Roblox account eligibility. Other capabilities remain
|
||||
// unsupported.
|
||||
class RobloxPermissionsBridge final {
|
||||
public:
|
||||
RobloxPermissionsBridge(JniEnvironmentProvider environment,
|
||||
@@ -60,26 +14,12 @@ class RobloxPermissionsBridge final {
|
||||
RobloxPermissionsMessageBusObjects objects,
|
||||
bool microphone_enabled);
|
||||
~RobloxPermissionsBridge();
|
||||
RobloxPermissionsBridge(const RobloxPermissionsBridge&) = delete;
|
||||
RobloxPermissionsBridge& operator=(const RobloxPermissionsBridge&) = delete;
|
||||
|
||||
Status Initialize();
|
||||
Status Shutdown();
|
||||
|
||||
private:
|
||||
struct State;
|
||||
Status ShutdownLocked();
|
||||
|
||||
const JniEnvironmentProvider environment_;
|
||||
const RobloxPermissionsMessageBusSymbols symbols_;
|
||||
const RobloxPermissionsMessageBusObjects objects_;
|
||||
const bool microphone_enabled_;
|
||||
// Serializes lifecycle only. Native dispatch never holds this mutex.
|
||||
std::mutex lifecycle_mutex_;
|
||||
std::shared_ptr<State> state_;
|
||||
std::unique_ptr<RobloxMessageBusRequestBridge> bridge_;
|
||||
};
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PERMISSIONS_BRIDGE_H_
|
||||
#endif
|
||||
@@ -0,0 +1,35 @@
|
||||
#ifndef MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
#define MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
#include "mocktail/status.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
struct ResolvedFollowUserPlace {
|
||||
int64_t place_id = 0;
|
||||
std::string game_instance_id; // Roblox's "gameId" for the running session.
|
||||
};
|
||||
|
||||
// Reads the session cookie mocktail already persists at
|
||||
// ~/.local/share/mocktail/auth/roblox.cookie (or $XDG_DATA_HOME
|
||||
// equivalent). Returns an empty string if no session is stored.
|
||||
std::string ReadStoredRoblosecurityCookie();
|
||||
|
||||
// Calls Roblox's presence API to find what place/instance a user is
|
||||
// currently in. `auth_cookie` is the runtime's already-authenticated
|
||||
// .ROBLOSECURITY (or equivalent bearer token) — this function performs no
|
||||
// authentication of its own and requires the runtime's validated session.
|
||||
//
|
||||
// Returns an error if the user is offline, has presence hidden, or is not
|
||||
// in a joinable experience (e.g. in Studio, or privacy settings block it).
|
||||
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
|
||||
ResolvedFollowUserPlace* out);
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
|
||||
#endif // MOCKTAIL_RUNTIME_ROBLOX_PRESENCE_RESOLVER_H_
|
||||
@@ -4,6 +4,7 @@
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -33,11 +34,18 @@ struct PayloadGarbageResult {
|
||||
explicit operator bool() const { return error.empty(); }
|
||||
};
|
||||
|
||||
// Optional per-store fault hook for atomic manifest writes. Returning true
|
||||
// makes the selected operation fail through its normal cleanup path.
|
||||
enum class ManifestWritePhase { kCreate, kWrite, kFileFsync, kRename };
|
||||
using ManifestWriteFault =
|
||||
std::function<bool(const std::filesystem::path&, ManifestWritePhase)>;
|
||||
|
||||
class PayloadStore final {
|
||||
public:
|
||||
PayloadStore(std::filesystem::path root,
|
||||
std::filesystem::path compatibility_manifest,
|
||||
std::filesystem::path runtime_binary = {});
|
||||
std::filesystem::path runtime_binary = {},
|
||||
ManifestWriteFault manifest_write_fault = {});
|
||||
|
||||
PayloadStoreResult Stage(const std::filesystem::path& prepared_payload);
|
||||
PayloadStoreResult Promote(std::string_view payload_id);
|
||||
@@ -60,6 +68,7 @@ class PayloadStore final {
|
||||
std::filesystem::path root_;
|
||||
std::filesystem::path compatibility_manifest_;
|
||||
std::filesystem::path runtime_binary_;
|
||||
ManifestWriteFault manifest_write_fault_;
|
||||
};
|
||||
|
||||
} // namespace mocktail::update
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#ifndef MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||
#define MOCKTAIL_UPDATE_READINESS_CANARY_H_
|
||||
|
||||
#include <spawn.h>
|
||||
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
@@ -22,6 +25,12 @@ std::string_view CanaryGraphicsBackendName(CanaryGraphicsBackend backend);
|
||||
bool ParseCanaryGraphicsBackend(std::string_view name,
|
||||
CanaryGraphicsBackend* backend);
|
||||
|
||||
// Optional per-request replacement for the POSIX spawn operation.
|
||||
// It follows posix_spawn return/ownership semantics; empty uses posix_spawn.
|
||||
using CanarySpawn =
|
||||
std::function<int(pid_t*, const char*, const posix_spawn_file_actions_t*,
|
||||
const posix_spawnattr_t*, char* const[], char* const[])>;
|
||||
|
||||
struct CanaryOptions {
|
||||
std::filesystem::path runtime_binary;
|
||||
std::filesystem::path payload_directory;
|
||||
@@ -29,9 +38,9 @@ struct CanaryOptions {
|
||||
std::filesystem::path host_abi_profile;
|
||||
std::filesystem::path cache_root;
|
||||
std::filesystem::path state_root;
|
||||
CanaryGraphicsBackend graphics_backend =
|
||||
CanaryGraphicsBackend::kDirectVulkan;
|
||||
CanaryGraphicsBackend graphics_backend = CanaryGraphicsBackend::kDirectVulkan;
|
||||
int timeout_seconds = 150;
|
||||
CanarySpawn spawn;
|
||||
|
||||
bool probation_candidate() const { return !host_abi_profile.empty(); }
|
||||
};
|
||||
@@ -39,6 +48,8 @@ struct CanaryOptions {
|
||||
struct CanaryResult {
|
||||
std::filesystem::path log_path;
|
||||
int exit_code = -1;
|
||||
// Direct posix_spawn return code; zero when spawn was not attempted.
|
||||
int spawn_error = 0;
|
||||
std::string error;
|
||||
|
||||
explicit operator bool() const {
|
||||
|
||||
@@ -26,6 +26,8 @@ struct UpdatePaths {
|
||||
// build the single file happens to describe.
|
||||
std::filesystem::path host_abi_reference_profile;
|
||||
std::filesystem::path runtime_binary;
|
||||
// Optional packaging wrapper; runtime_binary identifies the actual ELF.
|
||||
std::filesystem::path runtime_launcher;
|
||||
};
|
||||
|
||||
struct UpdateRequest {
|
||||
@@ -38,6 +40,7 @@ struct UpdateRequest {
|
||||
CanaryGraphicsBackend canary_graphics_backend =
|
||||
CanaryGraphicsBackend::kDirectVulkan;
|
||||
int progress_fd = -1;
|
||||
CanarySpawn canary_spawn;
|
||||
};
|
||||
|
||||
struct UpdateResult {
|
||||
|
||||
@@ -7,6 +7,7 @@ MOCKTAIL_PORTABLE_MODE=standalone
|
||||
MOCKTAIL_RUNTIME_LIBRARY_DIR=${SHARUN_DIR}/lib/mocktail
|
||||
MOCKTAIL_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_CANARY_BIN=${SHARUN_DIR}/bin/mocktail
|
||||
MOCKTAIL_UPDATE_RUNTIME_BINARY=${SHARUN_DIR}/shared/bin/mocktail
|
||||
MOCKTAIL_UPDATE_HELPER=${SHARUN_DIR}/bin/mocktail_updater
|
||||
MOCKTAIL_WEBVIEW_HELPER=${SHARUN_DIR}/bin/mocktail_webview_helper
|
||||
MOCKTAIL_FAILURE_DIALOG_HELPER=${SHARUN_DIR}/bin/mocktail_failure_dialog
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"--share=ipc",
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--talk-name=com.feralinteractive.GameMode",
|
||||
|
||||
@@ -0,0 +1,602 @@
|
||||
// cc -O2 -o mocktail_webview_host mocktail_webview_host.c $(pkg-config --cflags --libs gtk4 webkitgtk-6.0)
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <glib-unix.h>
|
||||
#include <gtk/gtk.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <signal.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#include <webkit/webkit.h>
|
||||
|
||||
#define PORT 2137
|
||||
#define MAX_PACKET (64u * 1024u + 12u)
|
||||
#define VERSION "2.725.1142"
|
||||
|
||||
enum {
|
||||
OP_LOAD_URL = 1, OP_SET_TITLE = 2, OP_SET_VISIBLE = 3, OP_EVAL_JS = 4,
|
||||
OP_CLOSE = 5, OP_SET_BACK_DISABLED = 6, OP_SHOW_DOMAIN_TITLE = 7,
|
||||
OP_SET_COOKIE = 8, OP_CLEAR_COOKIE = 9, OP_RETAIN_COOKIE = 10
|
||||
};
|
||||
|
||||
enum { EV_EXECUTE_ROBLOX = 1, EV_ROBLOX_WK_HYBRID = 2, EV_READY = 3, EV_COOKIE = 4 };
|
||||
|
||||
static const char kBridge[] =
|
||||
"(() => { 'use strict';"
|
||||
" const h = window.webkit && window.webkit.messageHandlers;"
|
||||
" const handler = h && h.executeRoblox; if (!handler) return;"
|
||||
" const bridge = {};"
|
||||
" Object.defineProperty(bridge, 'executeRoblox', {"
|
||||
" value: (q) => handler.postMessage(JSON.parse(q)),"
|
||||
" enumerable: true, writable: false, configurable: false });"
|
||||
" try { Object.defineProperty(window, '__globalRobloxAndroidBridge__', {"
|
||||
" value: bridge, enumerable: true, writable: false, configurable: false });"
|
||||
" } catch (_) {} })();";
|
||||
|
||||
static GtkApplication *app;
|
||||
static GtkWindow *window;
|
||||
static WebKitWebView *view;
|
||||
static WebKitNetworkSession *session;
|
||||
static int listener = -1;
|
||||
static int client = -1;
|
||||
static guint client_read_source;
|
||||
static guint client_write_source;
|
||||
static GByteArray *rx;
|
||||
static GQueue *tx;
|
||||
static size_t tx_offset;
|
||||
static gboolean domain_title;
|
||||
static gboolean login_page;
|
||||
static gboolean back_disabled;
|
||||
static gboolean shutting_down;
|
||||
static char *last_cookie;
|
||||
|
||||
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data);
|
||||
static gboolean on_client(gint fd, GIOCondition cond, gpointer data);
|
||||
static void apply(uint8_t op, const char *payload, size_t size);
|
||||
static void create_surface(void);
|
||||
static void destroy_surface(void);
|
||||
|
||||
static void drop_client(const char *why) {
|
||||
if (client < 0) return;
|
||||
fprintf(stderr, "client dropped: %s\n", why);
|
||||
if (client_read_source) {
|
||||
g_source_remove(client_read_source);
|
||||
client_read_source = 0;
|
||||
}
|
||||
if (client_write_source) {
|
||||
g_source_remove(client_write_source);
|
||||
client_write_source = 0;
|
||||
}
|
||||
close(client);
|
||||
client = -1;
|
||||
g_byte_array_set_size(rx, 0);
|
||||
while (!g_queue_is_empty(tx)) g_bytes_unref(g_queue_pop_head(tx));
|
||||
tx_offset = 0;
|
||||
g_free(last_cookie);
|
||||
last_cookie = NULL;
|
||||
destroy_surface();
|
||||
}
|
||||
|
||||
static void quit_app(void) {
|
||||
if (shutting_down) return;
|
||||
shutting_down = TRUE;
|
||||
drop_client("shutting down");
|
||||
if (app) g_application_quit(G_APPLICATION(app));
|
||||
}
|
||||
|
||||
static void send_event(uint8_t type, const char *payload, size_t size) {
|
||||
if (client < 0) return;
|
||||
size_t body = 12 + size;
|
||||
uint8_t *buffer = g_malloc0(4 + body);
|
||||
buffer[0] = body >> 24; buffer[1] = body >> 16; buffer[2] = body >> 8; buffer[3] = body;
|
||||
memcpy(buffer + 4, "MWVE", 4);
|
||||
buffer[8] = 1;
|
||||
buffer[9] = type;
|
||||
buffer[12] = size >> 24; buffer[13] = size >> 16; buffer[14] = size >> 8; buffer[15] = size;
|
||||
if (size) memcpy(buffer + 16, payload, size);
|
||||
g_queue_push_tail(tx, g_bytes_new_take(buffer, 4 + body));
|
||||
if (!client_write_source)
|
||||
client_write_source = g_unix_fd_add(client, G_IO_OUT | G_IO_HUP | G_IO_ERR, flush_tx, NULL);
|
||||
}
|
||||
|
||||
static gboolean flush_tx(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)data;
|
||||
if (cond & (G_IO_HUP | G_IO_ERR)) {
|
||||
client_write_source = 0;
|
||||
drop_client("write side closed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
while (!g_queue_is_empty(tx)) {
|
||||
GBytes *head = g_queue_peek_head(tx);
|
||||
gsize length;
|
||||
const uint8_t *bytes = g_bytes_get_data(head, &length);
|
||||
ssize_t count = send(fd, bytes + tx_offset, length - tx_offset, MSG_DONTWAIT | MSG_NOSIGNAL);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return G_SOURCE_CONTINUE;
|
||||
if (count <= 0) {
|
||||
client_write_source = 0;
|
||||
drop_client("write failed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
tx_offset += (size_t)count;
|
||||
if (tx_offset == length) {
|
||||
tx_offset = 0;
|
||||
g_bytes_unref(g_queue_pop_head(tx));
|
||||
}
|
||||
}
|
||||
client_write_source = 0;
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
static char *user_agent(void) {
|
||||
const char *v = getenv("MOCKTAIL_ROBLOX_VERSION");
|
||||
if (!v || !*v) v = VERSION;
|
||||
return g_strdup_printf(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) ROBLOX Android App "
|
||||
"%s Tablet Hybrid() GooglePlayStore RobloxApp/%s (GlobalDist; GooglePlayStore)", v, v);
|
||||
}
|
||||
|
||||
static void on_cookies(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
GList *cookies = webkit_cookie_manager_get_cookies_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
for (GList *i = cookies; i; i = i->next) {
|
||||
SoupCookie *c = i->data;
|
||||
const char *name = soup_cookie_get_name(c);
|
||||
const char *value = soup_cookie_get_value(c);
|
||||
if (name && value && strcmp(name, ".ROBLOSECURITY") == 0 && *value &&
|
||||
(!last_cookie || strcmp(last_cookie, value) != 0)) {
|
||||
g_free(last_cookie);
|
||||
last_cookie = g_strdup(value);
|
||||
send_event(EV_COOKIE, value, strlen(value));
|
||||
break;
|
||||
}
|
||||
}
|
||||
g_list_free_full(cookies, (GDestroyNotify)soup_cookie_free);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void request_cookie(void) {
|
||||
if (!login_page || !session || client < 0) return;
|
||||
webkit_cookie_manager_get_cookies(webkit_network_session_get_cookie_manager(session),
|
||||
"https://www.roblox.com/", NULL, on_cookies, NULL);
|
||||
}
|
||||
|
||||
static void on_cookie_changed(WebKitCookieManager *manager, gpointer data) {
|
||||
(void)manager; (void)data;
|
||||
request_cookie();
|
||||
}
|
||||
|
||||
static void configure_agent(const char *url) {
|
||||
WebKitSettings *settings = webkit_web_view_get_settings(view);
|
||||
GUri *uri = g_uri_parse(url, G_URI_FLAGS_NONE, NULL);
|
||||
const char *path = uri ? g_uri_get_path(uri) : NULL;
|
||||
login_page = path && (strcmp(path, "/login") == 0 || strcmp(path, "/login/") == 0);
|
||||
if (uri) g_uri_unref(uri);
|
||||
if (login_page) {
|
||||
webkit_settings_set_user_agent(settings, NULL);
|
||||
request_cookie();
|
||||
} else {
|
||||
char *agent = user_agent();
|
||||
webkit_settings_set_user_agent(settings, agent);
|
||||
g_free(agent);
|
||||
}
|
||||
}
|
||||
|
||||
static void update_title(void) {
|
||||
if (!domain_title || !window || !view) return;
|
||||
const char *uri = webkit_web_view_get_uri(view);
|
||||
if (!uri) return;
|
||||
GUri *parsed = g_uri_parse(uri, G_URI_FLAGS_NONE, NULL);
|
||||
if (!parsed) return;
|
||||
const char *host = g_uri_get_host(parsed);
|
||||
if (host) gtk_window_set_title(window, host);
|
||||
g_uri_unref(parsed);
|
||||
}
|
||||
|
||||
static void on_load_changed(WebKitWebView *v, WebKitLoadEvent event, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
if (event == WEBKIT_LOAD_COMMITTED || event == WEBKIT_LOAD_FINISHED) {
|
||||
update_title();
|
||||
request_cookie();
|
||||
}
|
||||
}
|
||||
|
||||
static GtkWidget *on_create(WebKitWebView *v, WebKitNavigationAction *action, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
WebKitURIRequest *request = webkit_navigation_action_get_request(action);
|
||||
const char *uri = request ? webkit_uri_request_get_uri(request) : NULL;
|
||||
if (uri && view) webkit_web_view_load_uri(view, uri);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static char *json_string_after(const char *json, const char *key) {
|
||||
char *needle = g_strdup_printf("\"%s\"", key);
|
||||
const char *at = strstr(json, needle);
|
||||
g_free(needle);
|
||||
if (!at) return NULL;
|
||||
at = strchr(at + strlen(key) + 2, ':');
|
||||
if (!at) return NULL;
|
||||
at++;
|
||||
while (*at == ' ') at++;
|
||||
if (*at != '"') return NULL;
|
||||
at++;
|
||||
GString *out = g_string_new(NULL);
|
||||
while (*at && *at != '"') {
|
||||
if (*at == '\\' && at[1]) at++;
|
||||
g_string_append_c(out, *at);
|
||||
at++;
|
||||
}
|
||||
return g_string_free(out, FALSE);
|
||||
}
|
||||
|
||||
static void on_callback_done(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
gboolean close_after = GPOINTER_TO_INT(data);
|
||||
GError *error = NULL;
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
|
||||
gboolean confirmed = !error && value && jsc_value_is_boolean(value) && jsc_value_to_boolean(value);
|
||||
if (value) g_object_unref(value);
|
||||
g_clear_error(&error);
|
||||
if (confirmed && close_after) destroy_surface();
|
||||
}
|
||||
|
||||
static void acknowledge_page_command(const char *json) {
|
||||
if (!view || !json) return;
|
||||
char *feature = json_string_after(json, "feature");
|
||||
char *callback = json_string_after(json, "callbackID");
|
||||
if (feature && callback && *callback) {
|
||||
gboolean success = strcmp(feature, "CaptchaSuccess") == 0;
|
||||
if (success || strcmp(feature, "CaptchaShown") == 0) {
|
||||
GString *quoted = g_string_new("\"");
|
||||
for (const char *p = callback; *p; p++) {
|
||||
if (*p == '"' || *p == '\\') g_string_append_c(quoted, '\\');
|
||||
g_string_append_c(quoted, *p);
|
||||
}
|
||||
g_string_append_c(quoted, '"');
|
||||
char *script = g_strdup_printf(
|
||||
"(() => { try { if (window.Roblox && window.Roblox.Hybrid && "
|
||||
"window.Roblox.Hybrid.Bridge && typeof window.Roblox.Hybrid.Bridge.nativeCallback === 'function') { "
|
||||
"window.Roblox.Hybrid.Bridge.nativeCallback(%s, true, {}); return true; } } catch (_) {} return false; })();",
|
||||
quoted->str);
|
||||
webkit_web_view_evaluate_javascript(view, script, -1, NULL, NULL, NULL,
|
||||
on_callback_done, GINT_TO_POINTER(success));
|
||||
g_free(script);
|
||||
g_string_free(quoted, TRUE);
|
||||
}
|
||||
}
|
||||
g_free(feature);
|
||||
g_free(callback);
|
||||
}
|
||||
|
||||
static void forward(uint8_t type, JSCValue *value) {
|
||||
char *text = NULL;
|
||||
if (jsc_value_is_string(value)) text = jsc_value_to_string(value);
|
||||
else if (jsc_value_is_object(value)) text = jsc_value_to_json(value, 0);
|
||||
if (!text) return;
|
||||
size_t size = strlen(text);
|
||||
if (size && size <= 64u * 1024u) {
|
||||
send_event(type, text, size);
|
||||
acknowledge_page_command(text);
|
||||
}
|
||||
g_free(text);
|
||||
}
|
||||
|
||||
static void on_execute_roblox(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
|
||||
(void)m; (void)data;
|
||||
forward(EV_EXECUTE_ROBLOX, value);
|
||||
}
|
||||
|
||||
static void on_hybrid(WebKitUserContentManager *m, JSCValue *value, gpointer data) {
|
||||
(void)m; (void)data;
|
||||
if (!jsc_value_is_object(value)) return;
|
||||
JSCValue *command = jsc_value_object_get_property(value, "command");
|
||||
if (command) {
|
||||
forward(EV_ROBLOX_WK_HYBRID, command);
|
||||
g_object_unref(command);
|
||||
}
|
||||
}
|
||||
|
||||
static void on_view_close(WebKitWebView *v, gpointer data) {
|
||||
(void)v; (void)data;
|
||||
destroy_surface();
|
||||
}
|
||||
|
||||
static gboolean on_close_request(GtkWindow *w, gpointer data) {
|
||||
(void)w; (void)data;
|
||||
window = NULL;
|
||||
view = NULL;
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
static void on_reload(GtkButton *button, gpointer data) {
|
||||
(void)button; (void)data;
|
||||
if (view) webkit_web_view_reload_bypass_cache(view);
|
||||
}
|
||||
|
||||
static void on_eval(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
JSCValue *value = webkit_web_view_evaluate_javascript_finish(WEBKIT_WEB_VIEW(source), result, &error);
|
||||
if (value) g_object_unref(value);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void on_cookie_done(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
webkit_cookie_manager_add_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void on_cookie_deleted(GObject *source, GAsyncResult *result, gpointer data) {
|
||||
(void)data;
|
||||
GError *error = NULL;
|
||||
webkit_cookie_manager_delete_cookie_finish(WEBKIT_COOKIE_MANAGER(source), result, &error);
|
||||
g_clear_error(&error);
|
||||
}
|
||||
|
||||
static void ensure_session(void) {
|
||||
if (session) return;
|
||||
gchar *data_dir = g_build_filename(g_get_user_data_dir(), "mocktail", "webview", NULL);
|
||||
gchar *cache_dir = g_build_filename(g_get_user_cache_dir(), "mocktail", "webview", NULL);
|
||||
g_mkdir_with_parents(data_dir, 0700);
|
||||
g_mkdir_with_parents(cache_dir, 0700);
|
||||
gchar *cookie_db = g_build_filename(data_dir, "cookies.sqlite", NULL);
|
||||
session = webkit_network_session_new(data_dir, cache_dir);
|
||||
g_free(data_dir);
|
||||
g_free(cache_dir);
|
||||
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
|
||||
webkit_cookie_manager_set_persistent_storage(cookies, cookie_db, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
|
||||
webkit_cookie_manager_set_accept_policy(cookies, WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS);
|
||||
g_signal_connect(cookies, "changed", G_CALLBACK(on_cookie_changed), NULL);
|
||||
g_free(cookie_db);
|
||||
webkit_network_session_set_itp_enabled(session, FALSE);
|
||||
webkit_network_session_set_persistent_credential_storage_enabled(session, TRUE);
|
||||
}
|
||||
|
||||
static void create_surface(void) {
|
||||
if (window || view) return;
|
||||
ensure_session();
|
||||
|
||||
WebKitUserContentManager *manager = webkit_user_content_manager_new();
|
||||
WebKitWebView *created = WEBKIT_WEB_VIEW(g_object_new(WEBKIT_TYPE_WEB_VIEW, "network-session", session,
|
||||
"user-content-manager", manager, NULL));
|
||||
g_object_unref(manager);
|
||||
manager = webkit_web_view_get_user_content_manager(created);
|
||||
g_signal_connect(manager, "script-message-received::executeRoblox", G_CALLBACK(on_execute_roblox), NULL);
|
||||
g_signal_connect(manager, "script-message-received::RobloxWKHybrid", G_CALLBACK(on_hybrid), NULL);
|
||||
g_signal_connect(manager, "script-message-received::mocktailRobloxBridge", G_CALLBACK(on_execute_roblox), NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "executeRoblox", NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "RobloxWKHybrid", NULL);
|
||||
webkit_user_content_manager_register_script_message_handler(manager, "mocktailRobloxBridge", NULL);
|
||||
WebKitUserScript *script = webkit_user_script_new(kBridge, WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
|
||||
WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, NULL, NULL);
|
||||
webkit_user_content_manager_add_script(manager, script);
|
||||
webkit_user_script_unref(script);
|
||||
|
||||
WebKitSettings *settings = webkit_web_view_get_settings(created);
|
||||
webkit_settings_set_enable_javascript(settings, TRUE);
|
||||
webkit_settings_set_javascript_can_open_windows_automatically(settings, TRUE);
|
||||
webkit_settings_set_enable_back_forward_navigation_gestures(settings, !back_disabled);
|
||||
char *agent = user_agent();
|
||||
webkit_settings_set_user_agent(settings, agent);
|
||||
g_free(agent);
|
||||
|
||||
g_signal_connect(created, "load-changed", G_CALLBACK(on_load_changed), NULL);
|
||||
g_signal_connect(created, "create", G_CALLBACK(on_create), NULL);
|
||||
g_signal_connect(created, "close", G_CALLBACK(on_view_close), NULL);
|
||||
|
||||
GtkWindow *created_window = GTK_WINDOW(gtk_application_window_new(app));
|
||||
gtk_window_set_title(created_window, "Roblox");
|
||||
gtk_window_set_default_size(created_window, 1100, 760);
|
||||
GtkWidget *header = gtk_header_bar_new();
|
||||
GtkWidget *reload = gtk_button_new_from_icon_name("view-refresh-symbolic");
|
||||
g_signal_connect(reload, "clicked", G_CALLBACK(on_reload), NULL);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(header), reload);
|
||||
gtk_window_set_titlebar(created_window, header);
|
||||
gtk_widget_set_vexpand(GTK_WIDGET(created), TRUE);
|
||||
gtk_window_set_child(created_window, GTK_WIDGET(created));
|
||||
g_signal_connect(created_window, "close-request", G_CALLBACK(on_close_request), NULL);
|
||||
|
||||
view = created;
|
||||
window = created_window;
|
||||
}
|
||||
|
||||
static void destroy_surface(void) {
|
||||
GtkWindow *w = window;
|
||||
window = NULL;
|
||||
view = NULL;
|
||||
login_page = FALSE;
|
||||
domain_title = FALSE;
|
||||
if (w) gtk_window_destroy(w);
|
||||
}
|
||||
|
||||
static void apply(uint8_t op, const char *payload, size_t size) {
|
||||
if (op == OP_CLOSE) {
|
||||
destroy_surface();
|
||||
return;
|
||||
}
|
||||
if (op == OP_SET_BACK_DISABLED) {
|
||||
back_disabled = size == 1 && payload[0];
|
||||
if (view) webkit_settings_set_enable_back_forward_navigation_gestures(
|
||||
webkit_web_view_get_settings(view), !back_disabled);
|
||||
return;
|
||||
}
|
||||
if (op == OP_SHOW_DOMAIN_TITLE) {
|
||||
domain_title = size == 1 && payload[0];
|
||||
update_title();
|
||||
return;
|
||||
}
|
||||
if (op == OP_SET_COOKIE || op == OP_CLEAR_COOKIE) {
|
||||
ensure_session();
|
||||
WebKitCookieManager *cookies = webkit_network_session_get_cookie_manager(session);
|
||||
if (op == OP_SET_COOKIE) {
|
||||
char *value = g_strndup(payload, size);
|
||||
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", value, ".roblox.com", "/", -1);
|
||||
g_free(value);
|
||||
if (cookie) {
|
||||
soup_cookie_set_secure(cookie, TRUE);
|
||||
soup_cookie_set_http_only(cookie, TRUE);
|
||||
soup_cookie_set_same_site_policy(cookie, SOUP_SAME_SITE_POLICY_LAX);
|
||||
webkit_cookie_manager_add_cookie(cookies, cookie, NULL, on_cookie_done, NULL);
|
||||
soup_cookie_free(cookie);
|
||||
}
|
||||
} else {
|
||||
SoupCookie *cookie = soup_cookie_new(".ROBLOSECURITY", "", ".roblox.com", "/", -1);
|
||||
if (cookie) {
|
||||
webkit_cookie_manager_delete_cookie(cookies, cookie, NULL, on_cookie_deleted, NULL);
|
||||
soup_cookie_free(cookie);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (op == OP_RETAIN_COOKIE) return;
|
||||
if (op == OP_LOAD_URL) create_surface();
|
||||
if (!window || !view) return;
|
||||
switch (op) {
|
||||
case OP_LOAD_URL: {
|
||||
char *url = g_strndup(payload, size);
|
||||
configure_agent(url);
|
||||
webkit_web_view_load_uri(view, url);
|
||||
gtk_window_present(window);
|
||||
g_free(url);
|
||||
break;
|
||||
}
|
||||
case OP_SET_TITLE: {
|
||||
char *title = g_strndup(payload, size);
|
||||
gtk_window_set_title(window, title);
|
||||
g_free(title);
|
||||
break;
|
||||
}
|
||||
case OP_SET_VISIBLE:
|
||||
if (size == 1 && payload[0]) gtk_window_present(window);
|
||||
else gtk_widget_set_visible(GTK_WIDGET(window), FALSE);
|
||||
break;
|
||||
case OP_EVAL_JS:
|
||||
webkit_web_view_evaluate_javascript(view, payload, (gssize)size, NULL, NULL, NULL, on_eval, NULL);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static gboolean process_frames(void) {
|
||||
while (rx->len >= 4) {
|
||||
uint32_t length = ((uint32_t)rx->data[0] << 24) | ((uint32_t)rx->data[1] << 16) |
|
||||
((uint32_t)rx->data[2] << 8) | rx->data[3];
|
||||
if (length > MAX_PACKET) return FALSE;
|
||||
if (rx->len < 4u + length) break;
|
||||
uint8_t *copy = g_memdup2(rx->data + 4, length);
|
||||
g_byte_array_remove_range(rx, 0, 4 + length);
|
||||
if (length >= 12 && memcmp(copy, "MWVC", 4) == 0) {
|
||||
size_t size = ((size_t)copy[8] << 24) | ((size_t)copy[9] << 16) |
|
||||
((size_t)copy[10] << 8) | copy[11];
|
||||
if (size == length - 12) apply(copy[5], (const char *)copy + 12, size);
|
||||
}
|
||||
g_free(copy);
|
||||
if (client < 0) return TRUE;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
static gboolean on_client(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)data;
|
||||
uint8_t buffer[8192];
|
||||
while (TRUE) {
|
||||
ssize_t count = read(fd, buffer, sizeof buffer);
|
||||
if (count < 0 && errno == EINTR) continue;
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
|
||||
if (count <= 0) {
|
||||
client_read_source = 0;
|
||||
drop_client("client closed");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
g_byte_array_append(rx, buffer, (guint)count);
|
||||
if (!process_frames()) {
|
||||
client_read_source = 0;
|
||||
drop_client("bad frame");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
if (client < 0) return G_SOURCE_REMOVE;
|
||||
}
|
||||
if (cond & (G_IO_HUP | G_IO_ERR)) {
|
||||
client_read_source = 0;
|
||||
drop_client("client hung up");
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static gboolean on_listener(gint fd, GIOCondition cond, gpointer data) {
|
||||
(void)cond; (void)data;
|
||||
int accepted = accept(fd, NULL, NULL);
|
||||
if (accepted < 0) return G_SOURCE_CONTINUE;
|
||||
if (client >= 0) drop_client("replaced by a new connection");
|
||||
fcntl(accepted, F_SETFD, FD_CLOEXEC);
|
||||
fcntl(accepted, F_SETFL, fcntl(accepted, F_GETFL) | O_NONBLOCK);
|
||||
int one = 1;
|
||||
setsockopt(accepted, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);
|
||||
client = accepted;
|
||||
fprintf(stderr, "client connected\n");
|
||||
client_read_source = g_unix_fd_add(client, G_IO_IN | G_IO_HUP | G_IO_ERR, on_client, NULL);
|
||||
send_event(EV_READY, "", 0);
|
||||
return G_SOURCE_CONTINUE;
|
||||
}
|
||||
|
||||
static int listen_socket(void) {
|
||||
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (fd < 0) return -1;
|
||||
fcntl(fd, F_SETFD, FD_CLOEXEC);
|
||||
int one = 1;
|
||||
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
|
||||
struct sockaddr_in address;
|
||||
memset(&address, 0, sizeof address);
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(PORT);
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
if (bind(fd, (struct sockaddr *)&address, sizeof address) != 0 || listen(fd, 4) != 0) {
|
||||
close(fd);
|
||||
return -1;
|
||||
}
|
||||
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
|
||||
return fd;
|
||||
}
|
||||
|
||||
static void activate(GtkApplication *application, gpointer data) {
|
||||
(void)application; (void)data;
|
||||
}
|
||||
|
||||
static gboolean on_sigterm(gpointer data) {
|
||||
(void)data;
|
||||
quit_app();
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
(void)argc;
|
||||
setenv("WEBKIT_DISABLE_DMABUF_RENDERER", "1", 0);
|
||||
setenv("WEBKIT_DISABLE_COMPOSITING_MODE", "1", 0);
|
||||
rx = g_byte_array_new();
|
||||
tx = g_queue_new();
|
||||
listener = listen_socket();
|
||||
if (listener < 0) {
|
||||
fprintf(stderr, "cannot listen on 127.0.0.1:%d: %s\n", PORT, strerror(errno));
|
||||
return 1;
|
||||
}
|
||||
g_unix_fd_add(listener, G_IO_IN, on_listener, NULL);
|
||||
g_unix_signal_add(SIGTERM, on_sigterm, NULL);
|
||||
g_unix_signal_add(SIGINT, on_sigterm, NULL);
|
||||
app = gtk_application_new("org.mocktail.WebViewHost", G_APPLICATION_NON_UNIQUE);
|
||||
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
|
||||
g_application_hold(G_APPLICATION(app));
|
||||
int status = g_application_run(G_APPLICATION(app), 1, argv);
|
||||
g_object_unref(app);
|
||||
return status;
|
||||
}
|
||||
@@ -57,6 +57,20 @@
|
||||
<binary>mocktail</binary>
|
||||
</provides>
|
||||
<releases>
|
||||
<release version="0.3.3" date="2026-10-04">
|
||||
<description>
|
||||
<ul>
|
||||
<li>Joining a friend from a follow user link now works.</li>
|
||||
<li>Roblox textures decode about 3 times faster, so loading stalls are shorter.</li>
|
||||
<li>A failed Roblox update now restores your previous install instead of leaving it half changed.</li>
|
||||
<li>Fixed crashes and freezes with audio, the text overlay, the mouse lock and saved themes.</li>
|
||||
<li>Going to another page in the web view no longer closes it.</li>
|
||||
<li>Clicking while typing no longer closes the text box.</li>
|
||||
<li>Fixed the sky showing the wrong textures.</li>
|
||||
<li>The Flatpak build on NVIDIA now uses X11 to avoid Wayland hangs.</li>
|
||||
</ul>
|
||||
</description>
|
||||
</release>
|
||||
<release version="0.3.2" date="2026-09-29">
|
||||
<description>
|
||||
<ul>
|
||||
|
||||
@@ -409,9 +409,11 @@ WriteClassicPixbufCache() {
|
||||
Die "classic GdkPixbuf loader cache is unavailable"
|
||||
sed -E \
|
||||
-e 's@^# LoaderDir = .*$@# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders@' \
|
||||
-e 's#^"[^"]*/(libpixbufloader[-_][^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
|
||||
-e 's#^"[^"]*/([^/"]+\.so)"$#"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/\1"#' \
|
||||
"${source_cache}" >"${destination_cache}"
|
||||
if grep -Eq '^"/(usr|lib|lib64|opt|home|hdd|tmp)/' \
|
||||
# Module records contain only a quoted path. Image signatures such as XPM
|
||||
# can also start with '/' but have additional fields after the string.
|
||||
if grep -Eq '^"/[^"]*"$' \
|
||||
"${destination_cache}"; then
|
||||
Die "GdkPixbuf cache still contains an absolute loader path"
|
||||
fi
|
||||
@@ -663,4 +665,6 @@ Main() {
|
||||
PublishResources
|
||||
}
|
||||
|
||||
Main "$@"
|
||||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||||
Main "$@"
|
||||
fi
|
||||
@@ -28,6 +28,7 @@ STORE_ROOT=""
|
||||
TEMP_DIR=""
|
||||
ROLLBACK_DIR=""
|
||||
ROLLBACK_NEEDED=false
|
||||
BACKUP_COMPLETE=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
@@ -146,34 +147,54 @@ certificate_digests() {
|
||||
}
|
||||
|
||||
restore_payload() {
|
||||
[[ "${ROLLBACK_NEEDED}" == true && -d "${ROLLBACK_DIR}" ]] || return 0
|
||||
[[ "${ROLLBACK_NEEDED}" == true ]] || return 0
|
||||
[[ -d "${ROLLBACK_DIR}" ]] || return 1
|
||||
|
||||
log WARN "restoring the previous payload"
|
||||
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")"
|
||||
mkdir -p "${RBX_BIN_DIR}" "$(dirname -- "${METADATA_PATH}")" || return 1
|
||||
|
||||
local path
|
||||
for path in libroblox.so sober_apk assets; do
|
||||
if [[ -e "${RBX_BIN_DIR}/${path}" ]]; then
|
||||
mv -- "${RBX_BIN_DIR}/${path}" "${TEMP_DIR}/failed-${path}"
|
||||
local path active_path failed_path restore_status=0
|
||||
for path in libroblox.so sober_apk assets roblox_payload.json; do
|
||||
active_path="${RBX_BIN_DIR}/${path}"
|
||||
failed_path="${TEMP_DIR}/failed-${path}"
|
||||
if [[ "${path}" == roblox_payload.json ]]; then
|
||||
active_path="${METADATA_PATH}"
|
||||
failed_path="${TEMP_DIR}/failed-metadata.json"
|
||||
fi
|
||||
if [[ -e "${ROLLBACK_DIR}/${path}" ]]; then
|
||||
mv -- "${ROLLBACK_DIR}/${path}" "${RBX_BIN_DIR}/${path}"
|
||||
|
||||
# Before activation, anything still active belongs to the old payload.
|
||||
# Only components removed by the backup need restoring in this phase.
|
||||
if [[ "${BACKUP_COMPLETE}" == false ]]; then
|
||||
if [[ -e "${active_path}" || -L "${active_path}" ||
|
||||
! -e "${ROLLBACK_DIR}/${path}" && ! -L "${ROLLBACK_DIR}/${path}" ]]; then
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -e "${active_path}" || -L "${active_path}" ]]; then
|
||||
# Do not restore onto an occupied destination if removing the new
|
||||
# component fails: mv could overwrite a file or nest a directory.
|
||||
if ! mv -- "${active_path}" "${failed_path}"; then
|
||||
restore_status=1
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if [[ -e "${ROLLBACK_DIR}/${path}" || -L "${ROLLBACK_DIR}/${path}" ]]; then
|
||||
mv -- "${ROLLBACK_DIR}/${path}" "${active_path}" || restore_status=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -e "${METADATA_PATH}" ]]; then
|
||||
mv -- "${METADATA_PATH}" "${TEMP_DIR}/failed-metadata.json"
|
||||
fi
|
||||
if [[ -e "${ROLLBACK_DIR}/roblox_payload.json" ]]; then
|
||||
mv -- "${ROLLBACK_DIR}/roblox_payload.json" "${METADATA_PATH}"
|
||||
fi
|
||||
return "${restore_status}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local -r status=$?
|
||||
local status=$?
|
||||
trap - EXIT
|
||||
set +e
|
||||
restore_payload
|
||||
if ! restore_payload; then
|
||||
log ERROR "payload restoration failed; recovery files retained at ${TEMP_DIR}"
|
||||
(( status != 0 )) || status=1
|
||||
exit "${status}"
|
||||
fi
|
||||
if [[ -n "${TEMP_DIR}" && -d "${TEMP_DIR}" ]]; then
|
||||
rm -rf -- "${TEMP_DIR}"
|
||||
fi
|
||||
@@ -401,17 +422,18 @@ main() {
|
||||
mkdir -p "${VERSIONS_DIR}" "$(dirname -- "${METADATA_PATH}")"
|
||||
ROLLBACK_DIR="${TEMP_DIR}/rollback"
|
||||
mkdir -p "${ROLLBACK_DIR}"
|
||||
ROLLBACK_NEEDED=true
|
||||
|
||||
local active_path
|
||||
for active_path in libroblox.so sober_apk assets; do
|
||||
if [[ -e "${RBX_BIN_DIR}/${active_path}" ]]; then
|
||||
if [[ -e "${RBX_BIN_DIR}/${active_path}" || -L "${RBX_BIN_DIR}/${active_path}" ]]; then
|
||||
mv -- "${RBX_BIN_DIR}/${active_path}" "${ROLLBACK_DIR}/${active_path}"
|
||||
fi
|
||||
done
|
||||
if [[ -e "${METADATA_PATH}" ]]; then
|
||||
if [[ -e "${METADATA_PATH}" || -L "${METADATA_PATH}" ]]; then
|
||||
mv -- "${METADATA_PATH}" "${ROLLBACK_DIR}/roblox_payload.json"
|
||||
fi
|
||||
ROLLBACK_NEEDED=true
|
||||
BACKUP_COMPLETE=true
|
||||
|
||||
mv -- "${stage_dir}/libroblox.so" "${RBX_BIN_DIR}/libroblox.so"
|
||||
mv -- "${stage_dir}/sober_apk" "${RBX_BIN_DIR}/sober_apk"
|
||||
|
||||
@@ -12,9 +12,7 @@
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail::audio {
|
||||
namespace {
|
||||
@@ -55,10 +53,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
};
|
||||
|
||||
struct ReleaseTicket {
|
||||
std::shared_ptr<State> state;
|
||||
std::uint64_t id = 0;
|
||||
std::uint64_t generation = 0;
|
||||
bool in_use = false;
|
||||
const std::shared_ptr<State> state;
|
||||
const std::uint64_t generation;
|
||||
};
|
||||
|
||||
struct CallbackJob {
|
||||
@@ -71,13 +67,7 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
: sink(std::move(owned_sink)),
|
||||
max_buffers(options.max_buffers),
|
||||
event_callback(options.event_callback),
|
||||
event_context(options.event_context),
|
||||
ticket_pool(options.max_buffers) {
|
||||
free_tickets.reserve(options.max_buffers);
|
||||
for (std::size_t i = options.max_buffers; i > 0; --i) {
|
||||
free_tickets.push_back(i - 1);
|
||||
}
|
||||
}
|
||||
event_context(options.event_context) {}
|
||||
|
||||
static const AndroidSimpleBufferQueueTable kQueueTable;
|
||||
|
||||
@@ -103,7 +93,6 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
||||
|
||||
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
||||
std::uint64_t id = 0;
|
||||
ReleaseTicket* ticket = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
@@ -111,24 +100,24 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
return opensl_abi::kResultPreconditionsViolated;
|
||||
}
|
||||
if (state->pending_count.load(std::memory_order_relaxed) >=
|
||||
state->max_buffers ||
|
||||
state->free_tickets.empty()) {
|
||||
state->max_buffers) {
|
||||
return opensl_abi::kResultBufferInsufficient;
|
||||
}
|
||||
const std::size_t slot_index = state->free_tickets.back();
|
||||
state->free_tickets.pop_back();
|
||||
ticket = &state->ticket_pool[slot_index];
|
||||
id = state->next_id++;
|
||||
ticket->state = state;
|
||||
ticket->id = id;
|
||||
ticket->generation = state->generation;
|
||||
ticket->in_use = true;
|
||||
ticket = new (std::nothrow)
|
||||
ReleaseTicket{state, state->generation};
|
||||
if (ticket == nullptr) {
|
||||
state->last_status = Status::Error(
|
||||
StatusCode::kUnavailable, "unable to allocate OpenSL release ticket");
|
||||
return ToOpenSlResult(state->last_status);
|
||||
}
|
||||
state->pending_count.fetch_add(1, std::memory_order_relaxed);
|
||||
++state->submitted_buffers;
|
||||
}
|
||||
|
||||
const PcmBuffer pcm{buffer, static_cast<std::size_t>(size),
|
||||
&State::ReleaseBuffer, ticket};
|
||||
// A successful enqueue may release synchronously. Only a failed enqueue
|
||||
// leaves ownership here; its contract forbids a release callback.
|
||||
const Status status = state->sink->Enqueue(pcm);
|
||||
if (status.ok()) {
|
||||
state->NotifyEvent(OpenSlBufferQueueEvent::kSubmitted,
|
||||
@@ -136,22 +125,12 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
return opensl_abi::kResultSuccess;
|
||||
}
|
||||
|
||||
const std::unique_ptr<ReleaseTicket> failed_ticket(ticket);
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
state->last_status = status;
|
||||
if (ticket->in_use && ticket->id == id) {
|
||||
ticket->in_use = false;
|
||||
ticket->state.reset();
|
||||
const std::size_t slot_index =
|
||||
static_cast<std::size_t>(ticket - state->ticket_pool.data());
|
||||
state->free_tickets.push_back(slot_index);
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (state->submitted_buffers > 0) {
|
||||
--state->submitted_buffers;
|
||||
}
|
||||
}
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
--state->submitted_buffers;
|
||||
}
|
||||
return ToOpenSlResult(status);
|
||||
}
|
||||
@@ -164,25 +143,14 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
}
|
||||
const std::shared_ptr<State> state = raw_state->shared_from_this();
|
||||
std::lock_guard<std::mutex> operation_lock(state->sink_operation_mutex);
|
||||
std::size_t discarded_count = 0;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (state->stopping) {
|
||||
return opensl_abi::kResultPreconditionsViolated;
|
||||
}
|
||||
++state->generation;
|
||||
state->free_tickets.clear();
|
||||
for (auto& ticket : state->ticket_pool) {
|
||||
if (ticket.in_use) {
|
||||
ticket.in_use = false;
|
||||
ticket.state.reset();
|
||||
++discarded_count;
|
||||
}
|
||||
}
|
||||
for (std::size_t i = state->max_buffers; i > 0; --i) {
|
||||
state->free_tickets.push_back(i - 1);
|
||||
}
|
||||
state->discarded_buffers += discarded_count;
|
||||
state->discarded_buffers +=
|
||||
state->pending_count.load(std::memory_order_relaxed);
|
||||
state->pending_count.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
const Status status = state->sink->Clear();
|
||||
@@ -226,7 +194,8 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
|
||||
static void ReleaseBuffer(void* context, const void* /*data*/,
|
||||
std::size_t size_bytes) {
|
||||
auto* ticket = static_cast<ReleaseTicket*>(context);
|
||||
const std::unique_ptr<ReleaseTicket> ticket(
|
||||
static_cast<ReleaseTicket*>(context));
|
||||
if (ticket == nullptr) {
|
||||
return;
|
||||
}
|
||||
@@ -239,29 +208,27 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
bool discarded = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (ticket->in_use) {
|
||||
ticket->in_use = false;
|
||||
ticket->state.reset();
|
||||
const std::size_t slot_index =
|
||||
static_cast<std::size_t>(ticket - state->ticket_pool.data());
|
||||
state->free_tickets.push_back(slot_index);
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (!state->stopping && ticket->generation == state->generation) {
|
||||
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
|
||||
++state->consumed_buffers;
|
||||
state->consumed_bytes += size_bytes;
|
||||
consumed = true;
|
||||
if (state->callback != nullptr) {
|
||||
state->callback_jobs.push_back(
|
||||
CallbackJob{state->callback, state->callback_context});
|
||||
notify_worker = true;
|
||||
}
|
||||
} else {
|
||||
++state->discarded_buffers;
|
||||
discarded = true;
|
||||
// Clear already counted older generations as discarded. Their tickets
|
||||
// remain alive until release without affecting newly enqueued buffers.
|
||||
if (ticket->generation != state->generation) {
|
||||
return;
|
||||
}
|
||||
if (state->pending_count.load(std::memory_order_relaxed) > 0) {
|
||||
state->pending_count.fetch_sub(1, std::memory_order_relaxed);
|
||||
}
|
||||
if (!state->stopping) {
|
||||
state->processed_buffers.fetch_add(1, std::memory_order_relaxed);
|
||||
++state->consumed_buffers;
|
||||
state->consumed_bytes += size_bytes;
|
||||
consumed = true;
|
||||
if (state->callback != nullptr) {
|
||||
state->callback_jobs.push_back(
|
||||
CallbackJob{state->callback, state->callback_context});
|
||||
notify_worker = true;
|
||||
}
|
||||
} else {
|
||||
++state->discarded_buffers;
|
||||
discarded = true;
|
||||
}
|
||||
}
|
||||
if (consumed) {
|
||||
@@ -374,14 +341,11 @@ struct OpenSlSimpleBufferQueueAdapter::State
|
||||
void* const event_context;
|
||||
Handle handle;
|
||||
pthread_t callback_thread{};
|
||||
std::vector<ReleaseTicket> ticket_pool;
|
||||
std::vector<std::size_t> free_tickets;
|
||||
std::atomic<std::size_t> pending_count{0};
|
||||
std::deque<CallbackJob> callback_jobs;
|
||||
AndroidSimpleBufferQueueCallback callback = nullptr;
|
||||
void* callback_context = nullptr;
|
||||
Status last_status;
|
||||
std::uint64_t next_id = 1;
|
||||
std::uint64_t generation = 1;
|
||||
std::atomic<std::uint64_t> processed_buffers{0};
|
||||
std::uint64_t submitted_buffers = 0;
|
||||
@@ -547,7 +511,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
|
||||
}
|
||||
state->shutdown_started = true;
|
||||
state->stopping = true;
|
||||
++state->generation;
|
||||
state->callback = nullptr;
|
||||
state->callback_context = nullptr;
|
||||
state->callback_jobs.clear();
|
||||
@@ -566,10 +529,6 @@ void OpenSlSimpleBufferQueueAdapter::Shutdown() {
|
||||
state->last_status = Status::Error(
|
||||
StatusCode::kPlatformError,
|
||||
"audio sink did not release all borrowed OpenSL buffers");
|
||||
for (auto& ticket : state->ticket_pool) {
|
||||
ticket.in_use = false;
|
||||
ticket.state.reset();
|
||||
}
|
||||
state->pending_count.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
state->shutdown_complete = true;
|
||||
|
||||
@@ -15,6 +15,49 @@ namespace {
|
||||
|
||||
constexpr int kBionicAddressInfoFailure = 4;
|
||||
constexpr int kBionicAddressInfoMemory = 6;
|
||||
constexpr int kBionicAddressInfoBadFlags = 3;
|
||||
|
||||
struct AddressInfoFlag {
|
||||
int bionic;
|
||||
int host;
|
||||
};
|
||||
|
||||
// Bundled Android netdb.h AI_MASK includes only these five flags.
|
||||
constexpr std::array<AddressInfoFlag, 5> kAddressInfoFlags = {{
|
||||
{0x00000001, AI_PASSIVE},
|
||||
{0x00000002, AI_CANONNAME},
|
||||
{0x00000004, AI_NUMERICHOST},
|
||||
{0x00000008, AI_NUMERICSERV},
|
||||
{0x00000400, AI_ADDRCONFIG},
|
||||
}};
|
||||
|
||||
constexpr int SupportedBionicAddressInfoFlags() noexcept {
|
||||
int flags = 0;
|
||||
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
|
||||
flags |= entry.bionic;
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
int HostAddressInfoFlags(int bionic_flags) noexcept {
|
||||
int flags = 0;
|
||||
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
|
||||
if ((bionic_flags & entry.bionic) != 0) {
|
||||
flags |= entry.host;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
int BionicAddressInfoFlags(int host_flags) noexcept {
|
||||
int flags = 0;
|
||||
for (const AddressInfoFlag& entry : kAddressInfoFlags) {
|
||||
if ((host_flags & entry.host) != 0) {
|
||||
flags |= entry.bionic;
|
||||
}
|
||||
}
|
||||
return flags;
|
||||
}
|
||||
|
||||
constexpr std::array<std::string_view, 3> kBlockedUploadSuffixes = {
|
||||
"crashes.rbxinfra.com",
|
||||
@@ -63,7 +106,7 @@ int BionicAddressInfoError(int host_error) noexcept {
|
||||
return 2;
|
||||
}
|
||||
if (host_error == EAI_BADFLAGS) {
|
||||
return 3;
|
||||
return kBionicAddressInfoBadFlags;
|
||||
}
|
||||
if (host_error == EAI_FAIL) {
|
||||
return kBionicAddressInfoFailure;
|
||||
@@ -102,7 +145,7 @@ int BionicAddressInfoError(int host_error) noexcept {
|
||||
addrinfo HostHints(const BionicAddressInfo* hints) noexcept {
|
||||
addrinfo host_hints{};
|
||||
if (hints != nullptr) {
|
||||
host_hints.ai_flags = hints->ai_flags;
|
||||
host_hints.ai_flags = HostAddressInfoFlags(hints->ai_flags);
|
||||
host_hints.ai_family = hints->ai_family;
|
||||
host_hints.ai_socktype = hints->ai_socktype;
|
||||
host_hints.ai_protocol = hints->ai_protocol;
|
||||
@@ -122,7 +165,7 @@ bool CopyHostAddressInfo(const addrinfo* host,
|
||||
BionicFreeAddressInfo(head);
|
||||
return false;
|
||||
}
|
||||
node->ai_flags = current->ai_flags;
|
||||
node->ai_flags = BionicAddressInfoFlags(current->ai_flags);
|
||||
node->ai_family = current->ai_family;
|
||||
node->ai_socktype = current->ai_socktype;
|
||||
node->ai_protocol = current->ai_protocol;
|
||||
@@ -187,6 +230,10 @@ int BionicGetAddressInfo(const char* node, const char* service,
|
||||
if (node != nullptr && IsBlockedCrashReportUploadHost(node)) {
|
||||
return kBionicAddressInfoNameNotFound;
|
||||
}
|
||||
if (hints != nullptr &&
|
||||
(hints->ai_flags & ~SupportedBionicAddressInfoFlags()) != 0) {
|
||||
return kBionicAddressInfoBadFlags;
|
||||
}
|
||||
|
||||
const addrinfo host_hints = HostHints(hints);
|
||||
addrinfo* host_result = nullptr;
|
||||
|
||||
@@ -67,9 +67,8 @@ bool HasSymbols(const SharedLibrary& library,
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
||||
BackendCapability CheckAngleLibraries(const AngleProbeOptions& options,
|
||||
bool initialize_display) {
|
||||
if (options.egl_library_path.empty() || options.gles_library_path.empty()) {
|
||||
return Unavailable(
|
||||
"pinned ANGLE EGL and GLES library paths are both required");
|
||||
@@ -104,6 +103,20 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
||||
missing);
|
||||
}
|
||||
|
||||
auto query_string =
|
||||
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
|
||||
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS);
|
||||
const std::string extension_list =
|
||||
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
|
||||
if (extension_list.find(" EGL_ANGLE_platform_angle ") == std::string::npos) {
|
||||
return Unavailable("EGL library does not support EGL_ANGLE_platform_angle");
|
||||
}
|
||||
if (!initialize_display) {
|
||||
return {GraphicsBackendKind::kAngleVulkan, CapabilityState::kLoadable,
|
||||
HardwareAcceleration::kUnknown,
|
||||
"ANGLE EGL/GLES entry points available"};
|
||||
}
|
||||
|
||||
auto get_proc_address =
|
||||
reinterpret_cast<PFNEGLGETPROCADDRESSPROC>(egl.Find("eglGetProcAddress"));
|
||||
auto get_platform_display = reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC>(
|
||||
@@ -121,9 +134,6 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
||||
reinterpret_cast<PFNEGLINITIALIZEPROC>(egl.Find("eglInitialize"));
|
||||
auto terminate =
|
||||
reinterpret_cast<PFNEGLTERMINATEPROC>(egl.Find("eglTerminate"));
|
||||
auto query_string =
|
||||
reinterpret_cast<PFNEGLQUERYSTRINGPROC>(egl.Find("eglQueryString"));
|
||||
|
||||
const EGLint device_type =
|
||||
options.allow_software_device
|
||||
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE
|
||||
@@ -173,5 +183,15 @@ BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
||||
std::move(detail)};
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
BackendCapability InspectAngleLibraries(const AngleProbeOptions& options) {
|
||||
return CheckAngleLibraries(options, false);
|
||||
}
|
||||
|
||||
BackendCapability ProbeAngleVulkan(const AngleProbeOptions& options) {
|
||||
return CheckAngleLibraries(options, true);
|
||||
}
|
||||
|
||||
} // namespace graphics
|
||||
} // namespace mocktail
|
||||
+266
-86
@@ -2,7 +2,11 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
#include <pthread.h>
|
||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
|
||||
#include <immintrin.h>
|
||||
#endif
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
@@ -42,9 +46,46 @@ int Expand5(int value) { return (value << 3) | (value >> 2); }
|
||||
int Expand6(int value) { return (value << 2) | (value >> 4); }
|
||||
int Expand7(int value) { return (value << 1) | (value >> 6); }
|
||||
|
||||
inline std::uint32_t MakeRgba32(int r, int g, int bl, int a) {
|
||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
|
||||
__m128i v = _mm_set_epi32(a, bl, g, r);
|
||||
__m128i p16 = _mm_packs_epi32(v, v);
|
||||
__m128i p8 = _mm_packus_epi16(p16, p16);
|
||||
return static_cast<std::uint32_t>(_mm_cvtsi128_si32(p8));
|
||||
#else
|
||||
return static_cast<std::uint32_t>(Clamp255(r)) |
|
||||
(static_cast<std::uint32_t>(Clamp255(g)) << 8) |
|
||||
(static_cast<std::uint32_t>(Clamp255(bl)) << 16) |
|
||||
(static_cast<std::uint32_t>(static_cast<std::uint8_t>(a)) << 24);
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
|
||||
struct ColumnShuffleTable {
|
||||
alignas(16) std::uint8_t masks[256][16];
|
||||
constexpr ColumnShuffleTable() : masks{} {
|
||||
for (int m = 0; m < 16; ++m) {
|
||||
for (int l = 0; l < 16; ++l) {
|
||||
const int idx = (m << 4) | l;
|
||||
for (int p = 0; p < 4; ++p) {
|
||||
const int color_idx = (((m >> p) & 1) << 1) | ((l >> p) & 1);
|
||||
for (int b = 0; b < 4; ++b) {
|
||||
masks[idx][p * 4 + b] = static_cast<std::uint8_t>(color_idx * 4 + b);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
inline constexpr ColumnShuffleTable kColShuffleTable{};
|
||||
#endif
|
||||
|
||||
// Decodes one ETC2 RGB block into 16 column-major RGBA texels (x * 4 + y).
|
||||
#if (defined(__GNUC__) || defined(__clang__)) && (defined(__x86_64__) || defined(_M_X64))
|
||||
__attribute__((target("ssse3")))
|
||||
#endif
|
||||
void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
std::array<std::uint8_t, 64>* out) {
|
||||
std::uint32_t* out32) {
|
||||
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
|
||||
const bool diff_or_opaque = (b[3] & 2) != 0;
|
||||
const bool opaque = !punchthrough || diff_or_opaque;
|
||||
@@ -53,13 +94,6 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
auto index_of = [msb, lsb](int texel) {
|
||||
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
|
||||
};
|
||||
auto write = [out](int texel, int r, int g, int bl, int a) {
|
||||
const std::size_t o = static_cast<std::size_t>(texel) * 4;
|
||||
(*out)[o] = Clamp255(r);
|
||||
(*out)[o + 1] = Clamp255(g);
|
||||
(*out)[o + 2] = Clamp255(bl);
|
||||
(*out)[o + 3] = static_cast<std::uint8_t>(a);
|
||||
};
|
||||
|
||||
const int r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
|
||||
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 7];
|
||||
@@ -100,13 +134,13 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
paint[3][c] = c2[c] - d;
|
||||
}
|
||||
}
|
||||
std::uint32_t paint32[4];
|
||||
for (int i = 0; i < 4; ++i) {
|
||||
paint32[i] = MakeRgba32(paint[i][0], paint[i][1], paint[i][2], 255);
|
||||
}
|
||||
for (int texel = 0; texel < 16; ++texel) {
|
||||
const int index = index_of(texel);
|
||||
if (!opaque && index == 2) {
|
||||
write(texel, 0, 0, 0, 0);
|
||||
} else {
|
||||
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
|
||||
}
|
||||
out32[texel] = (!opaque && index == 2) ? 0 : paint32[index];
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -124,9 +158,10 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
const int bv = Expand6(b[7] & 63);
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
|
||||
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
|
||||
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
|
||||
out32[x * 4 + y] = MakeRgba32(
|
||||
(x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
|
||||
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
|
||||
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -144,39 +179,108 @@ void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
}
|
||||
const int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
|
||||
const bool flipped = (b[3] & 1) != 0;
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
const int texel = x * 4 + y;
|
||||
const int index = index_of(texel);
|
||||
if (!opaque && index == 2) {
|
||||
write(texel, 0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
|
||||
|
||||
// Basis Universal palette precalculation: 8 colors per block instead of 48 additions/clamps.
|
||||
std::uint32_t palette[2][4];
|
||||
for (int sub = 0; sub < 2; ++sub) {
|
||||
for (int idx = 0; idx < 4; ++idx) {
|
||||
const int modifier =
|
||||
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
|
||||
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
|
||||
base[sub][2] + modifier, 255);
|
||||
(!opaque && idx == 0) ? 0 : kEtc1Modifiers[tables[sub]][idx];
|
||||
palette[sub][idx] = MakeRgba32(base[sub][0] + modifier,
|
||||
base[sub][1] + modifier,
|
||||
base[sub][2] + modifier, 255);
|
||||
}
|
||||
}
|
||||
|
||||
if (opaque) {
|
||||
#if defined(__SSSE3__) || defined(__x86_64__) || defined(_M_X64)
|
||||
const __m128i pal0 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[0]));
|
||||
const __m128i pal1 = _mm_loadu_si128(reinterpret_cast<const __m128i*>(palette[1]));
|
||||
if (!flipped) {
|
||||
for (int x = 0; x < 2; ++x) {
|
||||
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
|
||||
const __m128i mask = _mm_load_si128(
|
||||
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
|
||||
_mm_shuffle_epi8(pal0, mask));
|
||||
}
|
||||
for (int x = 2; x < 4; ++x) {
|
||||
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
|
||||
const __m128i mask = _mm_load_si128(
|
||||
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4),
|
||||
_mm_shuffle_epi8(pal1, mask));
|
||||
}
|
||||
} else {
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
const int combo = (((msb >> (x * 4)) & 15) << 4) | ((lsb >> (x * 4)) & 15);
|
||||
const __m128i mask = _mm_load_si128(
|
||||
reinterpret_cast<const __m128i*>(kColShuffleTable.masks[combo]));
|
||||
const __m128i col0 = _mm_shuffle_epi8(pal0, mask);
|
||||
const __m128i col1 = _mm_shuffle_epi8(pal1, mask);
|
||||
const __m128i blended =
|
||||
_mm_unpacklo_epi64(col0, _mm_srli_si128(col1, 8));
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(out32 + x * 4), blended);
|
||||
}
|
||||
}
|
||||
#else
|
||||
if (!flipped) {
|
||||
for (int x = 0; x < 2; ++x) {
|
||||
const std::uint32_t* pal = palette[0];
|
||||
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
|
||||
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
|
||||
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
|
||||
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
|
||||
}
|
||||
for (int x = 2; x < 4; ++x) {
|
||||
const std::uint32_t* pal = palette[1];
|
||||
out32[x * 4 + 0] = pal[index_of(x * 4 + 0)];
|
||||
out32[x * 4 + 1] = pal[index_of(x * 4 + 1)];
|
||||
out32[x * 4 + 2] = pal[index_of(x * 4 + 2)];
|
||||
out32[x * 4 + 3] = pal[index_of(x * 4 + 3)];
|
||||
}
|
||||
} else {
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
out32[x * 4 + 0] = palette[0][index_of(x * 4 + 0)];
|
||||
out32[x * 4 + 1] = palette[0][index_of(x * 4 + 1)];
|
||||
out32[x * 4 + 2] = palette[1][index_of(x * 4 + 2)];
|
||||
out32[x * 4 + 3] = palette[1][index_of(x * 4 + 3)];
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
const int texel = x * 4 + y;
|
||||
const int index = index_of(texel);
|
||||
if (index == 2) {
|
||||
out32[texel] = 0;
|
||||
} else {
|
||||
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
|
||||
out32[texel] = palette[sub][index];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// EAC 3-bit index for column-major texel (x * 4 + y).
|
||||
int EacIndex(const std::uint8_t* b, int texel) {
|
||||
inline std::uint64_t ReadEacBits(const std::uint8_t* b) {
|
||||
std::uint64_t bits = 0;
|
||||
for (int i = 2; i < 8; ++i) {
|
||||
bits = (bits << 8) | b[i];
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
inline int EacIndexFromBits(std::uint64_t bits, int texel) {
|
||||
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
|
||||
}
|
||||
|
||||
int EacModifier(const std::uint8_t* b, int texel) {
|
||||
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
|
||||
}
|
||||
|
||||
std::uint16_t DecodeR11(const std::uint8_t* b, int texel, bool is_signed) {
|
||||
const int multiplier = b[1] >> 4;
|
||||
const int modifier = EacModifier(b, texel);
|
||||
inline std::uint16_t DecodeR11Modifier(const std::uint8_t* b, int multiplier,
|
||||
const int* modifier_table,
|
||||
int modifier_idx, bool is_signed) {
|
||||
const int modifier = modifier_table[modifier_idx];
|
||||
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
|
||||
if (!is_signed) {
|
||||
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
|
||||
@@ -308,12 +412,18 @@ class DecodePool {
|
||||
run_ = run;
|
||||
context_ = context;
|
||||
unclaimed_ = helpers;
|
||||
running_ = helpers;
|
||||
running_.store(helpers, std::memory_order_relaxed);
|
||||
}
|
||||
for (std::size_t i = 0; i < helpers; ++i) {
|
||||
ready_.notify_one();
|
||||
}
|
||||
ready_.notify_all();
|
||||
run(context);
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
done_.wait(lock, [this] { return running_ == 0; });
|
||||
if (running_.load(std::memory_order_acquire) > 0) {
|
||||
std::unique_lock<std::mutex> lock(done_mutex_);
|
||||
done_.wait(lock, [this] {
|
||||
return running_.load(std::memory_order_acquire) == 0;
|
||||
});
|
||||
}
|
||||
run_ = nullptr;
|
||||
context_ = nullptr;
|
||||
return true;
|
||||
@@ -343,29 +453,33 @@ class DecodePool {
|
||||
}
|
||||
|
||||
void WorkerLoop() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
for (;;) {
|
||||
ready_.wait(lock, [this] { return unclaimed_ > 0; });
|
||||
--unclaimed_;
|
||||
void (*run)(void*) = run_;
|
||||
void* context = context_;
|
||||
lock.unlock();
|
||||
void (*run)(void*) = nullptr;
|
||||
void* context = nullptr;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
ready_.wait(lock, [this] { return unclaimed_ > 0; });
|
||||
--unclaimed_;
|
||||
run = run_;
|
||||
context = context_;
|
||||
}
|
||||
run(context);
|
||||
lock.lock();
|
||||
if (--running_ == 0) {
|
||||
done_.notify_all();
|
||||
if (running_.fetch_sub(1, std::memory_order_acq_rel) == 1) {
|
||||
std::lock_guard<std::mutex> lock(done_mutex_);
|
||||
done_.notify_one();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::mutex batch_mutex_;
|
||||
std::mutex mutex_;
|
||||
std::mutex done_mutex_;
|
||||
std::condition_variable ready_;
|
||||
std::condition_variable done_;
|
||||
void (*run_)(void*) = nullptr;
|
||||
void* context_ = nullptr;
|
||||
std::size_t unclaimed_ = 0;
|
||||
std::size_t running_ = 0;
|
||||
std::atomic<std::size_t> running_{0};
|
||||
std::size_t threads_ = 0;
|
||||
};
|
||||
|
||||
@@ -378,6 +492,16 @@ void DecodeEtcJobs(EtcDecodeJob* jobs, std::size_t count,
|
||||
}
|
||||
DecodeBandQueue queue;
|
||||
std::vector<DecodeBand>& bands = queue.bands;
|
||||
std::size_t estimated_bands = 0;
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
const EtcDecodeJob& job = jobs[index];
|
||||
const std::uint32_t blocks_wide = (job.width + 3) / 4;
|
||||
const std::uint32_t blocks_high = (job.height + 3) / 4;
|
||||
const std::uint32_t rows_per_band = static_cast<std::uint32_t>(
|
||||
std::max<std::uint64_t>(1, kBlocksPerBand / (blocks_wide ? blocks_wide : 1)));
|
||||
estimated_bands += (blocks_high + rows_per_band - 1) / (rows_per_band ? rows_per_band : 1);
|
||||
}
|
||||
bands.reserve(estimated_bands);
|
||||
std::uint64_t total_blocks = 0;
|
||||
for (std::size_t index = 0; index < count; ++index) {
|
||||
EtcDecodeJob& job = jobs[index];
|
||||
@@ -439,48 +563,104 @@ bool DecodeEtcImageBlockRows(EtcFormat format, const std::uint8_t* source,
|
||||
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
|
||||
const std::size_t block_bytes = EtcBlockBytes(format);
|
||||
|
||||
std::array<std::uint8_t, 64> rgba{};
|
||||
alignas(16) std::uint32_t cols[16];
|
||||
const std::uint64_t end_block_row =
|
||||
static_cast<std::uint64_t>(first_block_row) + block_row_count;
|
||||
const bool is_rgba_family = (format == EtcFormat::kEtc2Rgb8 ||
|
||||
format == EtcFormat::kEtc2Rgb8A1 ||
|
||||
format == EtcFormat::kEtc2Rgba8);
|
||||
|
||||
for (std::uint64_t by = first_block_row; by < end_block_row; ++by) {
|
||||
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
|
||||
const std::uint8_t* block =
|
||||
source + (by * blocks_wide + bx) * block_bytes;
|
||||
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
|
||||
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
|
||||
} else if (format == EtcFormat::kEtc2Rgba8) {
|
||||
DecodeRgbBlock(block + 8, false, &rgba);
|
||||
}
|
||||
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
|
||||
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
|
||||
const int texel = static_cast<int>(x * 4 + y);
|
||||
std::uint8_t* out =
|
||||
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
|
||||
switch (format) {
|
||||
case EtcFormat::kEtc2Rgb8:
|
||||
case EtcFormat::kEtc2Rgb8A1:
|
||||
case EtcFormat::kEtc2Rgba8:
|
||||
std::copy_n(rgba.data() + texel * 4, 4, out);
|
||||
if (format == EtcFormat::kEtc2Rgba8) {
|
||||
const int alpha =
|
||||
block[0] + EacModifier(block, texel) * (block[1] >> 4);
|
||||
out[3] = Clamp255(alpha);
|
||||
}
|
||||
break;
|
||||
case EtcFormat::kEacR11:
|
||||
case EtcFormat::kEacR11Signed:
|
||||
case EtcFormat::kEacRg11:
|
||||
case EtcFormat::kEacRg11Signed: {
|
||||
const bool is_signed = format == EtcFormat::kEacR11Signed ||
|
||||
format == EtcFormat::kEacRg11Signed;
|
||||
const int channels = texel_bytes / 2;
|
||||
for (int channel = 0; channel < channels; ++channel) {
|
||||
const std::uint16_t value =
|
||||
DecodeR11(block + channel * 8, texel, is_signed);
|
||||
out[channel * 2] = static_cast<std::uint8_t>(value);
|
||||
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
|
||||
}
|
||||
break;
|
||||
const bool interior = (by * 4 + 4 <= height) && (bx * 4 + 4 <= width);
|
||||
|
||||
if (is_rgba_family) {
|
||||
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
|
||||
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, cols);
|
||||
} else {
|
||||
DecodeRgbBlock(block + 8, false, cols);
|
||||
const std::uint64_t alpha_bits = ReadEacBits(block);
|
||||
const int alpha_base = block[0];
|
||||
const int alpha_multiplier = block[1] >> 4;
|
||||
const auto& alpha_table = kEacModifiers[block[1] & 15];
|
||||
std::uint8_t alpha_palette[8];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
alpha_palette[i] =
|
||||
Clamp255(alpha_base + alpha_table[i] * alpha_multiplier);
|
||||
}
|
||||
for (int texel = 0; texel < 16; ++texel) {
|
||||
cols[texel] =
|
||||
(cols[texel] & 0x00FFFFFF) |
|
||||
(static_cast<std::uint32_t>(
|
||||
alpha_palette[EacIndexFromBits(alpha_bits, texel)])
|
||||
<< 24);
|
||||
}
|
||||
}
|
||||
|
||||
if (interior) {
|
||||
std::uint32_t* dst_row = reinterpret_cast<std::uint32_t*>(
|
||||
destination + (by * 4 * width + bx * 4) * 4);
|
||||
#if defined(__x86_64__) || defined(_M_X64) || defined(__i386__) || defined(_M_IX86)
|
||||
__m128 r0 = _mm_load_ps(reinterpret_cast<const float*>(cols));
|
||||
__m128 r1 = _mm_load_ps(reinterpret_cast<const float*>(cols + 4));
|
||||
__m128 r2 = _mm_load_ps(reinterpret_cast<const float*>(cols + 8));
|
||||
__m128 r3 = _mm_load_ps(reinterpret_cast<const float*>(cols + 12));
|
||||
_MM_TRANSPOSE4_PS(r0, r1, r2, r3);
|
||||
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r0);
|
||||
dst_row += width;
|
||||
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r1);
|
||||
dst_row += width;
|
||||
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r2);
|
||||
dst_row += width;
|
||||
_mm_storeu_ps(reinterpret_cast<float*>(dst_row), r3);
|
||||
#else
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
dst_row[0] = cols[y];
|
||||
dst_row[1] = cols[4 + y];
|
||||
dst_row[2] = cols[8 + y];
|
||||
dst_row[3] = cols[12 + y];
|
||||
dst_row += width;
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
|
||||
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
|
||||
const int texel = static_cast<int>(x * 4 + y);
|
||||
std::uint32_t* out = reinterpret_cast<std::uint32_t*>(
|
||||
destination + ((by * 4 + y) * width + bx * 4 + x) * 4);
|
||||
*out = cols[texel];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const bool is_signed = format == EtcFormat::kEacR11Signed ||
|
||||
format == EtcFormat::kEacRg11Signed;
|
||||
const int channels = static_cast<int>(texel_bytes / 2);
|
||||
std::uint64_t channel_bits[2] = {0, 0};
|
||||
std::uint16_t channel_palette[2][8];
|
||||
for (int c = 0; c < channels; ++c) {
|
||||
const std::uint8_t* cb = block + c * 8;
|
||||
channel_bits[c] = ReadEacBits(cb);
|
||||
const int mult = cb[1] >> 4;
|
||||
const int* table = kEacModifiers[cb[1] & 15];
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
channel_palette[c][i] =
|
||||
DecodeR11Modifier(cb, mult, table, i, is_signed);
|
||||
}
|
||||
}
|
||||
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
|
||||
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
|
||||
const int texel = static_cast<int>(x * 4 + y);
|
||||
std::uint8_t* out =
|
||||
destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
|
||||
for (int channel = 0; channel < channels; ++channel) {
|
||||
const std::uint16_t value =
|
||||
channel_palette[channel][EacIndexFromBits(channel_bits[channel],
|
||||
texel)];
|
||||
out[channel * 2] = static_cast<std::uint8_t>(value);
|
||||
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,20 +72,33 @@ void ResampleRgba(const RgbaImage& source, std::uint32_t width,
|
||||
static_cast<std::size_t>(source.width) * source.height * 4) {
|
||||
return;
|
||||
}
|
||||
ResampleRgba(source.pixels.data(), source.width, source.height, width,
|
||||
height, destination);
|
||||
ResampleRgba(source.pixels.data(), source.width, source.height,
|
||||
static_cast<std::size_t>(source.width) * 4, width, height,
|
||||
destination);
|
||||
}
|
||||
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::uint32_t width,
|
||||
std::uint32_t height, std::uint8_t* destination) {
|
||||
ResampleRgba(source, source_width, source_height,
|
||||
static_cast<std::size_t>(source_width) * 4, width, height,
|
||||
destination);
|
||||
}
|
||||
|
||||
void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
std::uint32_t source_height, std::size_t source_stride_bytes,
|
||||
std::uint32_t width, std::uint32_t height,
|
||||
std::uint8_t* destination) {
|
||||
if (source == nullptr || destination == nullptr || width == 0 ||
|
||||
height == 0 || source_width == 0 || source_height == 0) {
|
||||
height == 0 || source_width == 0 || source_height == 0 ||
|
||||
source_stride_bytes < static_cast<std::size_t>(source_width) * 4) {
|
||||
return;
|
||||
}
|
||||
// Source box of destination column x is [columns[2x], columns[2x + 1]).
|
||||
thread_local std::vector<std::uint32_t> columns;
|
||||
columns.resize(static_cast<std::size_t>(width) * 2);
|
||||
if (columns.size() < static_cast<std::size_t>(width) * 2) {
|
||||
columns.resize(static_cast<std::size_t>(width) * 2);
|
||||
}
|
||||
bool one_texel_columns = true;
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
const std::uint32_t x0 = static_cast<std::uint32_t>(
|
||||
@@ -115,11 +128,11 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
previous_y0 = y0;
|
||||
previous_y1 = y1;
|
||||
if (one_texel_columns && y1 == y0 + 1) {
|
||||
const std::uint8_t* source_row = source + y0 * source_width * 4;
|
||||
const std::uint8_t* source_row = source + y0 * source_stride_bytes;
|
||||
for (std::uint32_t x = 0; x < width; ++x) {
|
||||
std::memcpy(out_row + static_cast<std::size_t>(x) * 4,
|
||||
source_row + columns[2 * static_cast<std::size_t>(x)] * 4,
|
||||
4);
|
||||
std::uint32_t pixel;
|
||||
std::memcpy(&pixel, source_row + columns[2 * static_cast<std::size_t>(x)] * 4, 4);
|
||||
std::memcpy(out_row + static_cast<std::size_t>(x) * 4, &pixel, 4);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -128,8 +141,9 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
const std::uint64_t x1 = columns[2 * static_cast<std::size_t>(x) + 1];
|
||||
std::uint64_t sum[4] = {0, 0, 0, 0};
|
||||
for (std::uint64_t sy = y0; sy < y1; ++sy) {
|
||||
const std::uint8_t* row_ptr = source + sy * source_stride_bytes;
|
||||
for (std::uint64_t sx = x0; sx < x1; ++sx) {
|
||||
const std::uint8_t* pixel = source + (sy * source_width + sx) * 4;
|
||||
const std::uint8_t* pixel = row_ptr + sx * 4;
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
sum[channel] += pixel[channel];
|
||||
}
|
||||
@@ -137,9 +151,16 @@ void ResampleRgba(const std::uint8_t* source, std::uint32_t source_width,
|
||||
}
|
||||
const std::uint64_t count = (y1 - y0) * (x1 - x0);
|
||||
std::uint8_t* out = destination + (static_cast<std::size_t>(y) * width + x) * 4;
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
out[channel] =
|
||||
static_cast<std::uint8_t>((sum[channel] + count / 2) / count);
|
||||
if (count == 1) {
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
out[channel] = static_cast<std::uint8_t>(sum[channel]);
|
||||
}
|
||||
} else {
|
||||
const std::uint64_t half = count / 2;
|
||||
for (int channel = 0; channel < 4; ++channel) {
|
||||
out[channel] =
|
||||
static_cast<std::uint8_t>((sum[channel] + half) / count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__x86_64__)
|
||||
#include <immintrin.h>
|
||||
#endif
|
||||
|
||||
namespace mocktail::graphics {
|
||||
|
||||
bool LookupEmulatedEtc2Format(VkFormat format, Etc2EmulatedFormat* out) {
|
||||
@@ -398,6 +402,45 @@ bool CreateStagingBlock(const HostDevice& dev, VkDeviceSize size,
|
||||
return true;
|
||||
}
|
||||
|
||||
#if defined(__x86_64__)
|
||||
__attribute__((target("sse4.1")))
|
||||
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
|
||||
std::size_t bytes) {
|
||||
if ((reinterpret_cast<std::uintptr_t>(src) & 15) != 0 || bytes < 16) {
|
||||
std::memcpy(dst, src, bytes);
|
||||
return;
|
||||
}
|
||||
std::size_t i = 0;
|
||||
for (; i + 64 <= bytes; i += 64) {
|
||||
__m128i c0 = _mm_stream_load_si128(
|
||||
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
|
||||
__m128i c1 = _mm_stream_load_si128(
|
||||
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 16)));
|
||||
__m128i c2 = _mm_stream_load_si128(
|
||||
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 32)));
|
||||
__m128i c3 = _mm_stream_load_si128(
|
||||
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i + 48)));
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c0);
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 16), c1);
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 32), c2);
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i + 48), c3);
|
||||
}
|
||||
for (; i + 16 <= bytes; i += 16) {
|
||||
__m128i c = _mm_stream_load_si128(
|
||||
reinterpret_cast<__m128i*>(const_cast<std::uint8_t*>(src + i)));
|
||||
_mm_storeu_si128(reinterpret_cast<__m128i*>(dst + i), c);
|
||||
}
|
||||
if (i < bytes) {
|
||||
std::memcpy(dst + i, src + i, bytes - i);
|
||||
}
|
||||
}
|
||||
#else
|
||||
inline void CopyHostVisibleMemory(std::uint8_t* dst, const std::uint8_t* src,
|
||||
std::size_t bytes) {
|
||||
std::memcpy(dst, src, bytes);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Packs each upload's compressed bytes into `destination`, dropping the row
|
||||
// and layer padding the application may have asked for. Sources are parallel
|
||||
// to `uploads`.
|
||||
@@ -411,17 +454,18 @@ void GatherCompressed(const std::vector<PendingUpload>& uploads,
|
||||
std::uint8_t* target = destination + offset;
|
||||
const VkDeviceSize compressed_layer = upload.compressed / upload.layers;
|
||||
if (upload.source_span == upload.compressed) {
|
||||
std::memcpy(target, source, upload.compressed);
|
||||
CopyHostVisibleMemory(target, source, upload.compressed);
|
||||
} else {
|
||||
const VkDeviceSize block_rows =
|
||||
(static_cast<VkDeviceSize>(upload.height) + 3) / 4;
|
||||
const VkDeviceSize packed_row = compressed_layer / block_rows;
|
||||
for (std::uint32_t layer = 0; layer < upload.layers; ++layer) {
|
||||
for (VkDeviceSize row = 0; row < block_rows; ++row) {
|
||||
std::memcpy(target + layer * compressed_layer + row * packed_row,
|
||||
source + layer * upload.source_layer_stride +
|
||||
row * upload.source_row_stride,
|
||||
packed_row);
|
||||
CopyHostVisibleMemory(
|
||||
target + layer * compressed_layer + row * packed_row,
|
||||
source + layer * upload.source_layer_stride +
|
||||
row * upload.source_row_stride,
|
||||
packed_row);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -710,21 +754,11 @@ struct VulkanEtc2Emulation::State {
|
||||
upload.target);
|
||||
return;
|
||||
}
|
||||
const std::uint8_t* source = decoded;
|
||||
std::vector<std::uint8_t> cropped;
|
||||
if (upload.source_width != upload.width ||
|
||||
upload.source_height != upload.height) {
|
||||
const std::size_t row = static_cast<std::size_t>(upload.source_width) * 4;
|
||||
cropped.resize(row * upload.source_height);
|
||||
for (std::uint32_t y = 0; y < upload.source_height; ++y) {
|
||||
std::memcpy(cropped.data() + y * row,
|
||||
decoded + static_cast<std::size_t>(y) * upload.width * 4,
|
||||
row);
|
||||
}
|
||||
source = cropped.data();
|
||||
}
|
||||
ResampleRgba(source, upload.source_width, upload.source_height,
|
||||
upload.target_width, upload.target_height, upload.target);
|
||||
const std::size_t source_stride =
|
||||
static_cast<std::size_t>(upload.width) * 4;
|
||||
ResampleRgba(decoded, upload.source_width, upload.source_height,
|
||||
source_stride, upload.target_width, upload.target_height,
|
||||
upload.target);
|
||||
} else {
|
||||
std::memcpy(upload.target, decoded, upload.decoded);
|
||||
}
|
||||
@@ -1700,9 +1734,7 @@ Etc2SubmitWait VulkanEtc2Emulation::PrepareSubmit(
|
||||
collect(command_buffers[index]);
|
||||
const auto record = state_->commands.find(command_buffers[index]);
|
||||
if (record != state_->commands.end()) {
|
||||
const std::vector<VkCommandBuffer> secondaries =
|
||||
record->second.secondaries;
|
||||
for (VkCommandBuffer secondary : secondaries) {
|
||||
for (VkCommandBuffer secondary : record->second.secondaries) {
|
||||
collect(secondary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,8 +749,10 @@ void VulkanTextOverlayCompositor::DestroyDevice(VkDevice device) {
|
||||
if (iterator == impl_->devices.end()) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
|
||||
impl_->DestroyDeviceResources(iterator->get());
|
||||
{
|
||||
std::lock_guard<std::mutex> overlay_lock((*iterator)->overlay_mutex);
|
||||
impl_->DestroyDeviceResources(iterator->get());
|
||||
}
|
||||
impl_->devices.erase(iterator);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#include "jni_fields.h"
|
||||
|
||||
#include "jni_references.h"
|
||||
|
||||
namespace jnivm::internal {
|
||||
namespace {
|
||||
std::unordered_set<std::string> g_field_names;
|
||||
std::unordered_map<std::string, jobject> g_static_object_fields;
|
||||
} // namespace
|
||||
|
||||
jfieldID StoreFieldId(const char* name) {
|
||||
if (name == nullptr) return nullptr;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
const auto stored = g_field_names.emplace(name).first;
|
||||
// unordered_set preserves references to its elements across rehashes.
|
||||
return reinterpret_cast<jfieldID>(const_cast<char*>(stored->c_str()));
|
||||
}
|
||||
|
||||
void SetStaticObjectFieldRaw(const char* name, jobject value) {
|
||||
if (name == nullptr) return;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
RetainJniReference(value);
|
||||
auto& field = g_static_object_fields[name];
|
||||
const jobject previous = field;
|
||||
field = value;
|
||||
ReleaseJniReference(previous);
|
||||
}
|
||||
|
||||
jobject StaticObjectFieldValue(const char* name) {
|
||||
if (name == nullptr) return nullptr;
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
const auto found = g_static_object_fields.find(name);
|
||||
return found == g_static_object_fields.end()
|
||||
? nullptr
|
||||
: NewLocalJniReference(found->second);
|
||||
}
|
||||
|
||||
void SetObjectFieldRaw(jobject obj, const char* field_name, jobject value) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (pseudo_object && field_name) {
|
||||
RetainJniReference(value);
|
||||
auto it = pseudo_object->object_fields.find(field_name);
|
||||
jobject prev =
|
||||
(it != pseudo_object->object_fields.end()) ? it->second : nullptr;
|
||||
pseudo_object->object_fields[field_name] = value;
|
||||
if (prev != nullptr) {
|
||||
ReleaseJniReference(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SetIntFieldRaw(jobject obj, const char* field_name, jint value) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (pseudo_object && field_name) {
|
||||
pseudo_object->int_fields[field_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
void SetLongFieldRaw(jobject obj, const char* field_name, jlong value) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (pseudo_object && field_name) {
|
||||
pseudo_object->long_fields[field_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
void SetFloatFieldRaw(jobject obj, const char* field_name, jfloat value) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (pseudo_object && field_name) {
|
||||
pseudo_object->float_fields[field_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
void SetBooleanFieldRaw(jobject obj, const char* field_name, jboolean value) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (pseudo_object && field_name) {
|
||||
pseudo_object->boolean_fields[field_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
jobject ObjectFieldValue(jobject obj, const char* field_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !field_name) {
|
||||
return nullptr;
|
||||
}
|
||||
auto it = pseudo_object->object_fields.find(field_name);
|
||||
return it == pseudo_object->object_fields.end()
|
||||
? nullptr
|
||||
: NewLocalJniReference(it->second);
|
||||
}
|
||||
|
||||
jint IntFieldValue(jobject obj, const char* field_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !field_name) {
|
||||
return 0;
|
||||
}
|
||||
auto it = pseudo_object->int_fields.find(field_name);
|
||||
return it == pseudo_object->int_fields.end() ? 0 : it->second;
|
||||
}
|
||||
|
||||
jboolean BooleanFieldValue(jobject obj, const char* field_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !field_name) {
|
||||
return JNI_FALSE;
|
||||
}
|
||||
auto it = pseudo_object->boolean_fields.find(field_name);
|
||||
return it == pseudo_object->boolean_fields.end() ? JNI_FALSE : it->second;
|
||||
}
|
||||
|
||||
jlong LongFieldValue(jobject obj, const char* field_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !field_name) {
|
||||
return 0;
|
||||
}
|
||||
auto it = pseudo_object->long_fields.find(field_name);
|
||||
return it == pseudo_object->long_fields.end() ? 0 : it->second;
|
||||
}
|
||||
|
||||
jfloat FloatFieldValue(jobject obj, const char* field_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !field_name) {
|
||||
return 0.0f;
|
||||
}
|
||||
auto it = pseudo_object->float_fields.find(field_name);
|
||||
return it == pseudo_object->float_fields.end() ? 0.0f : it->second;
|
||||
}
|
||||
|
||||
} // namespace jnivm::internal
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef MOCKTAIL_JNIVM_JNI_FIELDS_H_
|
||||
#define MOCKTAIL_JNIVM_JNI_FIELDS_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
namespace jnivm::internal {
|
||||
|
||||
// Field names are interned for the lifetime of the process. Object getters
|
||||
// return independently owned local references; setters retain their values.
|
||||
jfieldID StoreFieldId(const char* name);
|
||||
void SetStaticObjectFieldRaw(const char* name, jobject value);
|
||||
jobject StaticObjectFieldValue(const char* name);
|
||||
void SetObjectFieldRaw(jobject obj, const char* name, jobject value);
|
||||
void SetIntFieldRaw(jobject obj, const char* name, jint value);
|
||||
void SetLongFieldRaw(jobject obj, const char* name, jlong value);
|
||||
void SetFloatFieldRaw(jobject obj, const char* name, jfloat value);
|
||||
void SetBooleanFieldRaw(jobject obj, const char* name, jboolean value);
|
||||
jobject ObjectFieldValue(jobject obj, const char* name);
|
||||
jint IntFieldValue(jobject obj, const char* name);
|
||||
jlong LongFieldValue(jobject obj, const char* name);
|
||||
jfloat FloatFieldValue(jobject obj, const char* name);
|
||||
jboolean BooleanFieldValue(jobject obj, const char* name);
|
||||
|
||||
} // namespace jnivm::internal
|
||||
#endif
|
||||
@@ -0,0 +1,448 @@
|
||||
#include "jni_references.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace jnivm {
|
||||
// Encoded handles are also read by the guest ABI boundary.
|
||||
void* my_segment[100000] = {};
|
||||
int g_jni_ref_index = 1;
|
||||
} // namespace jnivm
|
||||
|
||||
namespace jnivm::internal {
|
||||
|
||||
enum class SegmentType : uint8_t {
|
||||
kEmpty = 0,
|
||||
kObject = 1,
|
||||
kClass = 2,
|
||||
};
|
||||
|
||||
// Class handles are interned by binary name. Real JNI resolves the same class
|
||||
// to the same runtime class object, and class handles are never released, so
|
||||
// every FindClass/GetObjectClass used to consume a fresh permanent segment
|
||||
// slot and exhaust the table during long sessions.
|
||||
// Each frame counts how many local references it holds per handle, so
|
||||
// DeleteLocalRef costs one hash lookup per open frame.
|
||||
using LocalFrame = std::unordered_map<jobject, uint32_t>;
|
||||
thread_local std::vector<LocalFrame> g_local_frames;
|
||||
std::recursive_mutex g_jni_state_mutex;
|
||||
std::unordered_map<jobject, std::unique_ptr<Object>> g_object_storage;
|
||||
std::unordered_map<jobject, uint32_t> g_jni_ref_counts;
|
||||
std::shared_ptr<void> g_segment_owners[kJniSegmentCapacity];
|
||||
std::unordered_set<jclass> g_known_classes;
|
||||
// One handle per Class. Class slots are never freed, so the table holds at
|
||||
// most one slot per distinct class.
|
||||
std::unordered_map<const Class*, jclass> g_class_handles;
|
||||
std::unordered_map<std::string, jobject> g_singleton_objects;
|
||||
std::unordered_set<jobject> g_singleton_handles;
|
||||
std::unordered_map<std::string, std::shared_ptr<Class>> g_fallback_classes;
|
||||
std::unordered_set<jstring> g_known_strings;
|
||||
std::unordered_map<jarray, std::unique_ptr<PseudoArray>> g_array_storage;
|
||||
static std::atomic<uint8_t> g_segment_types[kJniSegmentCapacity] = {};
|
||||
std::vector<int> g_free_slots;
|
||||
|
||||
static bool g_shutting_down_jnivm = false;
|
||||
|
||||
struct JniVmShutdownHook {
|
||||
JniVmShutdownHook() {
|
||||
std::atexit([]() { g_shutting_down_jnivm = true; });
|
||||
}
|
||||
};
|
||||
static JniVmShutdownHook g_shutdown_hook;
|
||||
|
||||
bool TraceEnabled() {
|
||||
static const bool enabled = std::getenv("MOCKTAIL_JNI_TRACE") != nullptr;
|
||||
return enabled;
|
||||
}
|
||||
|
||||
jobject JniHandleFromIndex(uint32_t index) {
|
||||
return reinterpret_cast<jobject>(static_cast<uintptr_t>(index)
|
||||
<< kJniHandleShift);
|
||||
}
|
||||
|
||||
uint32_t JniIndexFromHandle(jobject obj) {
|
||||
return static_cast<uint32_t>(reinterpret_cast<uintptr_t>(obj) >>
|
||||
kJniHandleShift);
|
||||
}
|
||||
|
||||
void EnsureLocalFrame() {
|
||||
if (g_local_frames.empty()) {
|
||||
g_local_frames.emplace_back();
|
||||
}
|
||||
}
|
||||
|
||||
void RegisterLocalRef(jobject obj) {
|
||||
if (obj == nullptr) {
|
||||
return;
|
||||
}
|
||||
EnsureLocalFrame();
|
||||
++g_local_frames.back()[obj];
|
||||
}
|
||||
|
||||
void UnregisterLocalRef(jobject obj) {
|
||||
if (obj == nullptr) {
|
||||
return;
|
||||
}
|
||||
for (auto frame = g_local_frames.rbegin(); frame != g_local_frames.rend();
|
||||
++frame) {
|
||||
auto it = frame->find(obj);
|
||||
if (it != frame->end()) {
|
||||
if (--it->second == 0) {
|
||||
frame->erase(it);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A detached thread can no longer use its local references, so they go the
|
||||
// way ART drops them on detach.
|
||||
void ReleaseThreadLocalRefs() {
|
||||
std::vector<LocalFrame> frames = std::move(g_local_frames);
|
||||
g_local_frames.clear();
|
||||
for (const LocalFrame& frame : frames) {
|
||||
for (const auto& [obj, count] : frame) {
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
ReleaseJniReference(obj);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RetainJniReference(jobject obj) {
|
||||
if (obj == nullptr) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto it = g_jni_ref_counts.find(obj);
|
||||
if (it != g_jni_ref_counts.end()) {
|
||||
++it->second;
|
||||
} else {
|
||||
g_jni_ref_counts[obj] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
void ReportHandleTableFull();
|
||||
|
||||
int AllocateSegmentSlot(void* value, std::shared_ptr<void> owner = nullptr,
|
||||
SegmentType type = SegmentType::kObject) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
int index = 0;
|
||||
if (!g_free_slots.empty()) {
|
||||
index = g_free_slots.back();
|
||||
g_free_slots.pop_back();
|
||||
} else if (g_jni_ref_index > 0 &&
|
||||
static_cast<uint32_t>(g_jni_ref_index) < kJniSegmentCapacity) {
|
||||
index = g_jni_ref_index++;
|
||||
} else {
|
||||
ReportHandleTableFull();
|
||||
return 0;
|
||||
}
|
||||
my_segment[index] = value;
|
||||
g_segment_owners[index] = std::move(owner);
|
||||
g_segment_types[index].store(static_cast<uint8_t>(type),
|
||||
std::memory_order_release);
|
||||
return index;
|
||||
}
|
||||
|
||||
std::shared_ptr<Class> FallbackClassForName(const std::string& class_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto it = g_fallback_classes.find(class_name);
|
||||
if (it != g_fallback_classes.end()) {
|
||||
return it->second;
|
||||
}
|
||||
auto cls = std::make_shared<Class>(class_name);
|
||||
g_fallback_classes[class_name] = cls;
|
||||
return cls;
|
||||
}
|
||||
|
||||
std::shared_ptr<Class> ClassFromJClass(jclass clazz) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (!clazz) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint32_t index = JniIndexFromHandle(reinterpret_cast<jobject>(clazz));
|
||||
Class* cls = nullptr;
|
||||
if (index > 0 && index < kJniSegmentCapacity) {
|
||||
cls = reinterpret_cast<Class*>(my_segment[index]);
|
||||
if (g_segment_owners[index]) {
|
||||
return std::static_pointer_cast<Class>(g_segment_owners[index]);
|
||||
}
|
||||
} else {
|
||||
cls = reinterpret_cast<Class*>(clazz);
|
||||
}
|
||||
if (!cls) {
|
||||
return nullptr;
|
||||
}
|
||||
return FallbackClassForName(cls->GetName());
|
||||
}
|
||||
|
||||
jclass StoreClass(std::shared_ptr<Class> cls) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (!cls) {
|
||||
return nullptr;
|
||||
}
|
||||
Class* raw_ptr = cls.get();
|
||||
if (const auto cached = g_class_handles.find(raw_ptr);
|
||||
cached != g_class_handles.end()) {
|
||||
return cached->second;
|
||||
}
|
||||
int index = AllocateSegmentSlot(raw_ptr, cls, SegmentType::kClass);
|
||||
if (index <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
jclass handle = reinterpret_cast<jclass>(
|
||||
JniHandleFromIndex(static_cast<uint32_t>(index)));
|
||||
g_known_classes.insert(handle);
|
||||
g_class_handles.emplace(raw_ptr, handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
jobject StoreObject(std::unique_ptr<Object> object) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
Object* raw_ptr = object.get();
|
||||
int index = AllocateSegmentSlot(raw_ptr, nullptr, SegmentType::kObject);
|
||||
if (index <= 0) {
|
||||
return nullptr;
|
||||
}
|
||||
jobject handle = JniHandleFromIndex(static_cast<uint32_t>(index));
|
||||
g_object_storage[handle] = std::move(object);
|
||||
g_jni_ref_counts[handle] = 1;
|
||||
RegisterLocalRef(handle);
|
||||
return handle;
|
||||
}
|
||||
|
||||
void ReleaseJniReference(jobject obj) {
|
||||
if (obj == nullptr || g_shutting_down_jnivm) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (g_shutting_down_jnivm) {
|
||||
return;
|
||||
}
|
||||
auto ref_it = g_jni_ref_counts.find(obj);
|
||||
if (ref_it != g_jni_ref_counts.end()) {
|
||||
if (ref_it->second > 1) {
|
||||
--ref_it->second;
|
||||
return;
|
||||
}
|
||||
g_jni_ref_counts.erase(ref_it);
|
||||
}
|
||||
|
||||
auto arr_it = g_array_storage.find(reinterpret_cast<jarray>(obj));
|
||||
if (arr_it != g_array_storage.end()) {
|
||||
std::unique_ptr<PseudoArray> dying_array = std::move(arr_it->second);
|
||||
std::vector<jobject> elements = std::move(dying_array->objects);
|
||||
g_array_storage.erase(arr_it);
|
||||
// Release after detaching the array: elements may recursively erase other
|
||||
// arrays. Keep dying_array alive until those releases have completed.
|
||||
for (jobject element : elements) {
|
||||
ReleaseJniReference(element);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (g_known_classes.find(reinterpret_cast<jclass>(obj)) !=
|
||||
g_known_classes.end()) {
|
||||
return;
|
||||
}
|
||||
if (g_singleton_handles.count(obj) != 0) {
|
||||
return;
|
||||
}
|
||||
const uint32_t index = JniIndexFromHandle(obj);
|
||||
if (index > 0 && index < kJniSegmentCapacity) {
|
||||
g_segment_types[index].store(static_cast<uint8_t>(SegmentType::kEmpty),
|
||||
std::memory_order_release);
|
||||
my_segment[index] = nullptr;
|
||||
g_segment_owners[index].reset();
|
||||
g_free_slots.push_back(static_cast<int>(index));
|
||||
}
|
||||
g_known_strings.erase(reinterpret_cast<jstring>(obj));
|
||||
std::unique_ptr<Object> dying_object;
|
||||
auto obj_it = g_object_storage.find(obj);
|
||||
if (obj_it != g_object_storage.end()) {
|
||||
dying_object = std::move(obj_it->second);
|
||||
g_object_storage.erase(obj_it);
|
||||
}
|
||||
}
|
||||
|
||||
PseudoJavaObject* PseudoObjectFromRef(jobject obj) {
|
||||
if (__builtin_expect(obj == nullptr, 0)) {
|
||||
return nullptr;
|
||||
}
|
||||
const uint32_t index = JniIndexFromHandle(obj);
|
||||
if (__builtin_expect(index > 0 && index < kJniSegmentCapacity, 1)) {
|
||||
const uint8_t type = g_segment_types[index].load(std::memory_order_acquire);
|
||||
if (__builtin_expect(type == static_cast<uint8_t>(SegmentType::kObject),
|
||||
1)) {
|
||||
void* raw_ptr = my_segment[index];
|
||||
if (__builtin_expect(raw_ptr != nullptr, 1)) {
|
||||
return static_cast<PseudoJavaObject*>(
|
||||
reinterpret_cast<Object*>(raw_ptr));
|
||||
}
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
jobject MakeObjectForClass(const std::string& class_name) {
|
||||
auto cls = FallbackClassForName(class_name);
|
||||
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
|
||||
}
|
||||
|
||||
jobject MakeObject(jclass clazz) {
|
||||
auto cls = ClassFromJClass(clazz);
|
||||
if (!cls) {
|
||||
cls = FallbackClassForName("java/lang/Object");
|
||||
}
|
||||
return StoreObject(std::make_unique<PseudoJavaObject>(std::move(cls)));
|
||||
}
|
||||
|
||||
jobject SingletonObject(const std::string& class_name) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto it = g_singleton_objects.find(class_name);
|
||||
if (it != g_singleton_objects.end()) {
|
||||
return it->second;
|
||||
}
|
||||
jobject object = MakeObjectForClass(class_name);
|
||||
g_singleton_objects[class_name] = object;
|
||||
g_singleton_handles.insert(object);
|
||||
return object;
|
||||
}
|
||||
|
||||
std::string_view ObjectClassName(jobject obj) {
|
||||
PseudoJavaObject* pseudo_object = PseudoObjectFromRef(obj);
|
||||
if (!pseudo_object || !pseudo_object->GetClass()) {
|
||||
return {};
|
||||
}
|
||||
return pseudo_object->GetClass()->GetName();
|
||||
}
|
||||
|
||||
void MarkSingletonHandle(jobject obj) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
g_singleton_handles.insert(obj);
|
||||
}
|
||||
|
||||
// Logs once per process which classes hold the live handles, so the leak
|
||||
// behind a later null FindClass or NewStringUTF can be named.
|
||||
void ReportHandleTableFull() {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
static bool reported = false;
|
||||
if (reported) {
|
||||
return;
|
||||
}
|
||||
reported = true;
|
||||
std::unordered_map<std::string_view, std::size_t> live_by_class;
|
||||
for (const auto& entry : g_object_storage) {
|
||||
std::string_view name = ObjectClassName(entry.first);
|
||||
++live_by_class[name.empty() ? std::string_view("<unknown>") : name];
|
||||
}
|
||||
std::vector<std::pair<std::string_view, std::size_t>> ranked(
|
||||
live_by_class.begin(), live_by_class.end());
|
||||
const std::size_t shown = std::min<std::size_t>(ranked.size(), 10);
|
||||
std::partial_sort(ranked.begin(), ranked.begin() + shown, ranked.end(),
|
||||
[](const auto& a, const auto& b) {
|
||||
return a.second > b.second;
|
||||
});
|
||||
fprintf(stderr,
|
||||
"[JNI] handle table full (%u slots): %zu objects, %zu classes, "
|
||||
"%zu arrays live; new objects return null\n",
|
||||
kJniSegmentCapacity, g_object_storage.size(),
|
||||
g_class_handles.size(), g_array_storage.size());
|
||||
for (std::size_t i = 0; i < shown; ++i) {
|
||||
fprintf(stderr, "[JNI] %zu x %.*s\n", ranked[i].second,
|
||||
static_cast<int>(ranked[i].first.size()), ranked[i].first.data());
|
||||
}
|
||||
}
|
||||
|
||||
PseudoArray* ArrayFromRef(jarray array) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto it = g_array_storage.find(array);
|
||||
return it == g_array_storage.end() ? nullptr : it->second.get();
|
||||
}
|
||||
|
||||
jbyteArray MakeByteArray(jsize len) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto array = std::make_unique<PseudoArray>();
|
||||
if (len > 0) {
|
||||
array->bytes.resize(static_cast<std::size_t>(len));
|
||||
}
|
||||
jbyteArray ref = reinterpret_cast<jbyteArray>(array.get());
|
||||
g_array_storage[ref] = std::move(array);
|
||||
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||
return ref;
|
||||
}
|
||||
|
||||
jfloatArray MakeFloatArray(jsize len) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto array = std::make_unique<PseudoArray>();
|
||||
if (len > 0) {
|
||||
array->floats.resize(static_cast<std::size_t>(len));
|
||||
}
|
||||
jfloatArray ref = reinterpret_cast<jfloatArray>(array.get());
|
||||
g_array_storage[ref] = std::move(array);
|
||||
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||
return ref;
|
||||
}
|
||||
|
||||
jobjectArray MakeObjectArray(jsize len, jobject init) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto array = std::make_unique<PseudoArray>();
|
||||
if (len > 0) {
|
||||
array->objects.resize(static_cast<std::size_t>(len), init);
|
||||
if (init != nullptr) {
|
||||
for (jsize index = 0; index < len; ++index) {
|
||||
RetainJniReference(init);
|
||||
}
|
||||
}
|
||||
}
|
||||
jobjectArray ref = reinterpret_cast<jobjectArray>(array.get());
|
||||
g_array_storage[ref] = std::move(array);
|
||||
g_jni_ref_counts[reinterpret_cast<jobject>(ref)] = 1;
|
||||
RegisterLocalRef(reinterpret_cast<jobject>(ref));
|
||||
return ref;
|
||||
}
|
||||
|
||||
PseudoJavaObject::~PseudoJavaObject() {
|
||||
for (const auto& field : object_fields) ReleaseJniReference(field.second);
|
||||
}
|
||||
|
||||
jobject NewLocalJniReference(jobject obj) {
|
||||
if (obj != nullptr) {
|
||||
RetainJniReference(obj);
|
||||
RegisterLocalRef(obj);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
void DeleteLocalJniReference(jobject obj) {
|
||||
UnregisterLocalRef(obj);
|
||||
ReleaseJniReference(obj);
|
||||
}
|
||||
|
||||
void PushLocalJniFrame() {
|
||||
EnsureLocalFrame();
|
||||
g_local_frames.emplace_back();
|
||||
}
|
||||
|
||||
jobject PopLocalJniFrame(jobject result) {
|
||||
EnsureLocalFrame();
|
||||
RetainJniReference(result);
|
||||
LocalFrame frame = std::move(g_local_frames.back());
|
||||
if (g_local_frames.size() > 1)
|
||||
g_local_frames.pop_back();
|
||||
else
|
||||
g_local_frames.back().clear();
|
||||
for (const auto& [obj, count] : frame) {
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
ReleaseJniReference(obj);
|
||||
}
|
||||
}
|
||||
RegisterLocalRef(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace jnivm::internal
|
||||
@@ -0,0 +1,65 @@
|
||||
#ifndef MOCKTAIL_JNIVM_JNI_REFERENCES_H_
|
||||
#define MOCKTAIL_JNIVM_JNI_REFERENCES_H_
|
||||
|
||||
#include <string_view>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
namespace jnivm::internal {
|
||||
|
||||
// Shared by reference ownership and object/field access. Callers retaining a
|
||||
// raw PseudoJavaObject pointer must keep its JNI reference alive.
|
||||
extern std::recursive_mutex g_jni_state_mutex;
|
||||
extern std::unordered_set<jstring> g_known_strings;
|
||||
|
||||
inline constexpr uint32_t kJniSegmentCapacity = 100000;
|
||||
inline constexpr uint32_t kJniHandleShift = 16;
|
||||
|
||||
struct PseudoArray {
|
||||
std::vector<jbyte> bytes;
|
||||
std::vector<jfloat> floats;
|
||||
std::vector<jobject> objects;
|
||||
};
|
||||
|
||||
struct PseudoJavaObject : Object {
|
||||
explicit PseudoJavaObject(std::shared_ptr<Class> cls)
|
||||
: Object(std::move(cls)) {}
|
||||
|
||||
~PseudoJavaObject() override;
|
||||
|
||||
std::unordered_map<std::string, jobject> object_fields;
|
||||
std::unordered_map<std::string, jint> int_fields;
|
||||
std::unordered_map<std::string, jlong> long_fields;
|
||||
std::unordered_map<std::string, jfloat> float_fields;
|
||||
std::unordered_map<std::string, jboolean> boolean_fields;
|
||||
};
|
||||
|
||||
bool TraceEnabled();
|
||||
void RegisterLocalRef(jobject obj);
|
||||
void UnregisterLocalRef(jobject obj);
|
||||
void ReleaseThreadLocalRefs();
|
||||
void RetainJniReference(jobject obj);
|
||||
void ReleaseJniReference(jobject obj);
|
||||
jobject NewLocalJniReference(jobject obj);
|
||||
void DeleteLocalJniReference(jobject obj);
|
||||
void PushLocalJniFrame();
|
||||
jobject PopLocalJniFrame(jobject result);
|
||||
std::shared_ptr<Class> FallbackClassForName(const std::string& name);
|
||||
std::shared_ptr<Class> ClassFromJClass(jclass clazz);
|
||||
jclass StoreClass(std::shared_ptr<Class> cls);
|
||||
jobject StoreObject(std::unique_ptr<Object> object);
|
||||
PseudoJavaObject* PseudoObjectFromRef(jobject obj);
|
||||
jobject MakeObjectForClass(const std::string& name);
|
||||
jobject MakeObject(jclass clazz);
|
||||
jobject SingletonObject(const std::string& name);
|
||||
// Marks a VM-owned handle so detaching its creating thread does not free it.
|
||||
void MarkSingletonHandle(jobject obj);
|
||||
std::string_view ObjectClassName(jobject obj);
|
||||
PseudoArray* ArrayFromRef(jarray array);
|
||||
jbyteArray MakeByteArray(jsize len);
|
||||
jfloatArray MakeFloatArray(jsize len);
|
||||
jobjectArray MakeObjectArray(jsize len, jobject init);
|
||||
|
||||
} // namespace jnivm::internal
|
||||
#endif
|
||||
@@ -0,0 +1,381 @@
|
||||
#include "jni_strings.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <iostream>
|
||||
#include <limits>
|
||||
|
||||
#include "jni_references.h"
|
||||
|
||||
namespace jnivm::internal {
|
||||
namespace {
|
||||
constexpr jchar kEmptyUtf16[] = {0};
|
||||
|
||||
void AppendUtf8CodePoint(std::uint32_t code_point, std::string* output) {
|
||||
if (code_point <= 0x7f) {
|
||||
output->push_back(static_cast<char>(code_point));
|
||||
} else if (code_point <= 0x7ff) {
|
||||
output->push_back(static_cast<char>(0xc0 | (code_point >> 6)));
|
||||
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
|
||||
} else if (code_point <= 0xffff) {
|
||||
output->push_back(static_cast<char>(0xe0 | (code_point >> 12)));
|
||||
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
|
||||
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
|
||||
} else {
|
||||
output->push_back(static_cast<char>(0xf0 | (code_point >> 18)));
|
||||
output->push_back(static_cast<char>(0x80 | ((code_point >> 12) & 0x3f)));
|
||||
output->push_back(static_cast<char>(0x80 | ((code_point >> 6) & 0x3f)));
|
||||
output->push_back(static_cast<char>(0x80 | (code_point & 0x3f)));
|
||||
}
|
||||
}
|
||||
|
||||
std::string Utf16ToUtf8(const std::vector<jchar>& utf16) {
|
||||
std::string output;
|
||||
output.reserve(utf16.size());
|
||||
for (std::size_t index = 0; index < utf16.size(); ++index) {
|
||||
std::uint32_t code_point = utf16[index];
|
||||
if (code_point >= 0xd800 && code_point <= 0xdbff &&
|
||||
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
|
||||
utf16[index + 1] <= 0xdfff) {
|
||||
code_point =
|
||||
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
|
||||
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
|
||||
code_point = 0xfffd;
|
||||
}
|
||||
AppendUtf8CodePoint(code_point, &output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
std::string Utf16ToModifiedUtf8(const std::vector<jchar>& utf16,
|
||||
std::size_t begin = 0,
|
||||
std::size_t count = std::string::npos) {
|
||||
std::string output;
|
||||
if (begin >= utf16.size()) {
|
||||
return output;
|
||||
}
|
||||
const std::size_t end =
|
||||
std::min(utf16.size(), begin + std::min(count, utf16.size() - begin));
|
||||
output.reserve((end - begin) * 3);
|
||||
for (std::size_t index = begin; index < end; ++index) {
|
||||
const std::uint32_t code_unit = utf16[index];
|
||||
if (code_unit == 0) {
|
||||
output.push_back(static_cast<char>(0xc0));
|
||||
output.push_back(static_cast<char>(0x80));
|
||||
} else if (code_unit <= 0x7f) {
|
||||
output.push_back(static_cast<char>(code_unit));
|
||||
} else if (code_unit <= 0x7ff) {
|
||||
output.push_back(static_cast<char>(0xc0 | (code_unit >> 6)));
|
||||
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
|
||||
} else {
|
||||
output.push_back(static_cast<char>(0xe0 | (code_unit >> 12)));
|
||||
output.push_back(static_cast<char>(0x80 | ((code_unit >> 6) & 0x3f)));
|
||||
output.push_back(static_cast<char>(0x80 | (code_unit & 0x3f)));
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
std::vector<jchar> ModifiedUtf8ToUtf16(const char* input) {
|
||||
std::vector<jchar> output;
|
||||
if (input == nullptr) {
|
||||
return output;
|
||||
}
|
||||
const auto* bytes = reinterpret_cast<const std::uint8_t*>(input);
|
||||
const std::size_t size = std::strlen(input);
|
||||
for (std::size_t index = 0; index < size;) {
|
||||
const std::uint8_t first = bytes[index];
|
||||
std::uint32_t code_point = 0xfffd;
|
||||
std::size_t consumed = 1;
|
||||
if (first != 0 && first <= 0x7f) {
|
||||
code_point = first;
|
||||
} else if ((first & 0xe0) == 0xc0 && index + 1 < size &&
|
||||
(bytes[index + 1] & 0xc0) == 0x80) {
|
||||
code_point = ((first & 0x1f) << 6) | (bytes[index + 1] & 0x3f);
|
||||
if (code_point == 0 || code_point >= 0x80) {
|
||||
consumed = 2;
|
||||
} else {
|
||||
code_point = 0xfffd;
|
||||
}
|
||||
} else if ((first & 0xf0) == 0xe0 && index + 2 < size &&
|
||||
(bytes[index + 1] & 0xc0) == 0x80 &&
|
||||
(bytes[index + 2] & 0xc0) == 0x80) {
|
||||
code_point = ((first & 0x0f) << 12) | ((bytes[index + 1] & 0x3f) << 6) |
|
||||
(bytes[index + 2] & 0x3f);
|
||||
if (code_point >= 0x800) {
|
||||
consumed = 3;
|
||||
} else {
|
||||
code_point = 0xfffd;
|
||||
}
|
||||
} else if ((first & 0xf8) == 0xf0 && index + 3 < size &&
|
||||
(bytes[index + 1] & 0xc0) == 0x80 &&
|
||||
(bytes[index + 2] & 0xc0) == 0x80 &&
|
||||
(bytes[index + 3] & 0xc0) == 0x80) {
|
||||
code_point = ((first & 0x07) << 18) | ((bytes[index + 1] & 0x3f) << 12) |
|
||||
((bytes[index + 2] & 0x3f) << 6) | (bytes[index + 3] & 0x3f);
|
||||
if (code_point >= 0x10000 && code_point <= 0x10ffff) {
|
||||
consumed = 4;
|
||||
} else {
|
||||
code_point = 0xfffd;
|
||||
}
|
||||
}
|
||||
if (code_point <= 0xffff) {
|
||||
output.push_back(static_cast<jchar>(code_point));
|
||||
} else {
|
||||
code_point -= 0x10000;
|
||||
output.push_back(static_cast<jchar>(0xd800 + (code_point >> 10)));
|
||||
output.push_back(static_cast<jchar>(0xdc00 + (code_point & 0x3ff)));
|
||||
}
|
||||
index += consumed;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
static std::vector<jchar> MakeUtf16Vector(const jchar* utf16, jsize length) {
|
||||
if (utf16 != nullptr && length > 0) {
|
||||
return std::vector<jchar>(utf16, utf16 + length);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
struct PseudoStringObject : PseudoJavaObject {
|
||||
PseudoStringObject(std::shared_ptr<Class> cls, const char* utf)
|
||||
: PseudoJavaObject(std::move(cls)),
|
||||
chars(ModifiedUtf8ToUtf16(utf)),
|
||||
value(Utf16ToUtf8(chars)),
|
||||
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
|
||||
|
||||
PseudoStringObject(std::shared_ptr<Class> cls, const jchar* utf16,
|
||||
jsize length)
|
||||
: PseudoJavaObject(std::move(cls)),
|
||||
chars(MakeUtf16Vector(utf16, length)),
|
||||
value(Utf16ToUtf8(chars)),
|
||||
modified_utf8(Utf16ToModifiedUtf8(chars)) {}
|
||||
|
||||
std::vector<jchar> chars;
|
||||
std::string value;
|
||||
std::string modified_utf8;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
jstring MakeString(const char* utf) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto cls = FallbackClassForName("java/lang/String");
|
||||
auto object = std::make_unique<PseudoStringObject>(std::move(cls), utf);
|
||||
jobject handle = StoreObject(std::move(object));
|
||||
jstring str = reinterpret_cast<jstring>(handle);
|
||||
g_known_strings.insert(str);
|
||||
return str;
|
||||
}
|
||||
|
||||
jstring MakeUtf16String(const jchar* utf16, jsize length) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto cls = FallbackClassForName("java/lang/String");
|
||||
auto object =
|
||||
std::make_unique<PseudoStringObject>(std::move(cls), utf16, length);
|
||||
jobject handle = StoreObject(std::move(object));
|
||||
jstring str = reinterpret_cast<jstring>(handle);
|
||||
g_known_strings.insert(str);
|
||||
return str;
|
||||
}
|
||||
|
||||
std::string_view StringViewFromJString(jstring str) {
|
||||
if (__builtin_expect(!str, 0)) {
|
||||
return {};
|
||||
}
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
if (string_object) {
|
||||
return string_object->value;
|
||||
}
|
||||
const char* utf = reinterpret_cast<const char*>(str);
|
||||
return utf ? std::string_view(utf) : std::string_view();
|
||||
}
|
||||
|
||||
std::string StringFromJString(jstring str) {
|
||||
const std::string_view sv = StringViewFromJString(str);
|
||||
return std::string(sv);
|
||||
}
|
||||
|
||||
bool IsUtf8CharsetName(jstring charset_name) {
|
||||
std::string normalized = StringFromJString(charset_name);
|
||||
for (char& character : normalized) {
|
||||
if (character >= 'A' && character <= 'Z') {
|
||||
character = static_cast<char>(character - 'A' + 'a');
|
||||
}
|
||||
}
|
||||
return normalized == "utf-8" || normalized == "utf8" ||
|
||||
normalized == "unicode-1-1-utf-8";
|
||||
}
|
||||
|
||||
std::string JavaStringUtf8Bytes(const std::vector<jchar>& utf16) {
|
||||
std::string output;
|
||||
output.reserve(utf16.size());
|
||||
for (std::size_t index = 0; index < utf16.size(); ++index) {
|
||||
std::uint32_t code_point = utf16[index];
|
||||
if (code_point >= 0xd800 && code_point <= 0xdbff &&
|
||||
index + 1 < utf16.size() && utf16[index + 1] >= 0xdc00 &&
|
||||
utf16[index + 1] <= 0xdfff) {
|
||||
code_point =
|
||||
0x10000 + ((code_point - 0xd800) << 10) + (utf16[++index] - 0xdc00);
|
||||
} else if (code_point >= 0xd800 && code_point <= 0xdfff) {
|
||||
// CharsetEncoder replaces each malformed UTF-16 unit with '?'.
|
||||
output.push_back('?');
|
||||
continue;
|
||||
}
|
||||
AppendUtf8CodePoint(code_point, &output);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name) {
|
||||
if (!IsUtf8CharsetName(charset_name)) {
|
||||
if (TraceEnabled()) {
|
||||
std::cerr << " [JNI] java/lang/String.getBytes rejected unsupported "
|
||||
"charset\n";
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
std::string bytes;
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* string_object =
|
||||
dynamic_cast<PseudoStringObject*>(PseudoObjectFromRef(obj));
|
||||
if (string_object == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
bytes = JavaStringUtf8Bytes(string_object->chars);
|
||||
}
|
||||
if (bytes.size() >
|
||||
static_cast<std::size_t>(std::numeric_limits<jsize>::max())) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
jbyteArray result = MakeByteArray(static_cast<jsize>(bytes.size()));
|
||||
PseudoArray* array = ArrayFromRef(result);
|
||||
if (array == nullptr || array->bytes.size() != bytes.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
if (!bytes.empty()) {
|
||||
std::memcpy(array->bytes.data(), bytes.data(), bytes.size());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static inline bool IsRawStringPointer(jstring str) {
|
||||
uintptr_t val = reinterpret_cast<uintptr_t>(str);
|
||||
if (val < 0x10000) {
|
||||
return false;
|
||||
}
|
||||
if ((val & 0xffff) == 0 && (val >> kJniHandleShift) < kJniSegmentCapacity) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* StringChars(jstring str) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (!str) {
|
||||
return nullptr;
|
||||
}
|
||||
if (g_known_strings.find(str) != g_known_strings.end()) {
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
return string_object != nullptr ? string_object->modified_utf8.c_str() : "";
|
||||
}
|
||||
if (!IsRawStringPointer(str)) {
|
||||
return "";
|
||||
}
|
||||
return reinterpret_cast<const char*>(str);
|
||||
}
|
||||
|
||||
const jchar* StringUtf16Chars(jstring str) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (!str) {
|
||||
return nullptr;
|
||||
}
|
||||
if (g_known_strings.find(str) != g_known_strings.end()) {
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
return string_object && !string_object->chars.empty()
|
||||
? string_object->chars.data()
|
||||
: kEmptyUtf16;
|
||||
}
|
||||
if (!IsRawStringPointer(str)) {
|
||||
return kEmptyUtf16;
|
||||
}
|
||||
return reinterpret_cast<const jchar*>(str);
|
||||
}
|
||||
|
||||
jsize StringUtf16Length(jstring str) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (g_known_strings.find(str) != g_known_strings.end()) {
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
return string_object != nullptr
|
||||
? static_cast<jsize>(string_object->chars.size())
|
||||
: 0;
|
||||
}
|
||||
if (!IsRawStringPointer(str)) {
|
||||
return 0;
|
||||
}
|
||||
const char* bytes = reinterpret_cast<const char*>(str);
|
||||
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
|
||||
}
|
||||
|
||||
jsize StringModifiedUtf8Length(jstring str) {
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
if (g_known_strings.find(str) != g_known_strings.end()) {
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
return string_object != nullptr
|
||||
? static_cast<jsize>(string_object->modified_utf8.size())
|
||||
: 0;
|
||||
}
|
||||
if (!IsRawStringPointer(str)) {
|
||||
return 0;
|
||||
}
|
||||
const char* bytes = reinterpret_cast<const char*>(str);
|
||||
return bytes != nullptr ? static_cast<jsize>(std::strlen(bytes)) : 0;
|
||||
}
|
||||
|
||||
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output) {
|
||||
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
if (string_object == nullptr ||
|
||||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
|
||||
return;
|
||||
}
|
||||
const std::size_t count =
|
||||
std::min(static_cast<std::size_t>(length),
|
||||
string_object->chars.size() - static_cast<std::size_t>(start));
|
||||
std::copy_n(string_object->chars.data() + start, count, output);
|
||||
}
|
||||
|
||||
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
|
||||
char* output) {
|
||||
if (str == nullptr || output == nullptr || start < 0 || length <= 0) {
|
||||
return;
|
||||
}
|
||||
std::lock_guard<std::recursive_mutex> lock(g_jni_state_mutex);
|
||||
auto* string_object = static_cast<PseudoStringObject*>(
|
||||
PseudoObjectFromRef(reinterpret_cast<jobject>(str)));
|
||||
if (string_object == nullptr ||
|
||||
static_cast<std::size_t>(start) >= string_object->chars.size()) {
|
||||
return;
|
||||
}
|
||||
const std::size_t count =
|
||||
std::min(static_cast<std::size_t>(length),
|
||||
string_object->chars.size() - static_cast<std::size_t>(start));
|
||||
const std::string encoded = Utf16ToModifiedUtf8(
|
||||
string_object->chars, static_cast<std::size_t>(start), count);
|
||||
std::memcpy(output, encoded.data(), encoded.size());
|
||||
}
|
||||
|
||||
} // namespace jnivm::internal
|
||||
@@ -0,0 +1,25 @@
|
||||
#ifndef MOCKTAIL_JNIVM_JNI_STRINGS_H_
|
||||
#define MOCKTAIL_JNIVM_JNI_STRINGS_H_
|
||||
|
||||
#include <jni.h>
|
||||
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace jnivm::internal {
|
||||
|
||||
jstring MakeString(const char* utf);
|
||||
jstring MakeUtf16String(const jchar* utf16, jsize length);
|
||||
std::string_view StringViewFromJString(jstring str);
|
||||
std::string StringFromJString(jstring str);
|
||||
jbyteArray JavaStringGetUtf8Bytes(jobject obj, jstring charset_name);
|
||||
const char* StringChars(jstring str);
|
||||
const jchar* StringUtf16Chars(jstring str);
|
||||
jsize StringUtf16Length(jstring str);
|
||||
jsize StringModifiedUtf8Length(jstring str);
|
||||
void CopyStringRegion(jstring str, jsize start, jsize length, jchar* output);
|
||||
void CopyStringModifiedUtf8Region(jstring str, jsize start, jsize length,
|
||||
char* output);
|
||||
|
||||
} // namespace jnivm::internal
|
||||
#endif
|
||||
+62
-1041
File diff suppressed because it is too large.
Load diff
@@ -4951,9 +4951,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
mocktail::runtime::ReadRobloxThemeCache(
|
||||
app_storage_file, dependencies.account_identity().user_id);
|
||||
if (!saved_theme) {
|
||||
std::cerr << "[FATAL] Cannot read Roblox theme cache: "
|
||||
std::cerr << " [theme] Cannot read Roblox theme cache; using default: "
|
||||
<< saved_theme.error << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if (saved_theme.dark_theme.has_value()) {
|
||||
dark_theme = *saved_theme.dark_theme;
|
||||
@@ -4964,7 +4963,7 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
if (runtime_config.theme_mode() == "roblox") {
|
||||
std::cout << " [theme] "
|
||||
<< (roblox_theme_found ? "Roblox saved theme="
|
||||
: "Roblox theme missing; default=")
|
||||
: "Roblox theme unavailable; default=")
|
||||
<< (dark_theme ? "dark" : "light") << '\n'
|
||||
<< std::flush;
|
||||
} else if (app_storage_file != nullptr) {
|
||||
@@ -4972,9 +4971,8 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
if (!mocktail::runtime::ApplyRobloxThemeCacheOverride(
|
||||
app_storage_file, dependencies.account_identity().user_id,
|
||||
dark_theme, &theme_error)) {
|
||||
std::cerr << "[FATAL] Roblox theme cache override failed: " << theme_error
|
||||
<< '\n';
|
||||
return EXIT_FAILURE;
|
||||
std::cerr << " [theme] Cannot persist Roblox theme override: "
|
||||
<< theme_error << '\n';
|
||||
}
|
||||
std::cout << " [theme] Roblox local theme="
|
||||
<< (dark_theme ? "dark" : "light") << '\n'
|
||||
@@ -6006,6 +6004,10 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
experience_game_symbols;
|
||||
std::shared_ptr<mocktail::runtime::RobloxExperienceComposition>
|
||||
experience_composition;
|
||||
std::unique_ptr<mocktail::runtime::RobloxPermissionsBridge>
|
||||
eager_permissions_bridge;
|
||||
std::unique_ptr<mocktail::runtime::RobloxCallProtocolBridge>
|
||||
eager_call_protocol_bridge;
|
||||
std::shared_ptr<ExperienceLifecycleTarget> experience_lifecycle_target;
|
||||
std::shared_ptr<mocktail::runtime::RobloxWindowInputRuntime>
|
||||
window_input_runtime;
|
||||
@@ -6665,6 +6667,75 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
<< " run_start_app_with_params="
|
||||
<< (run_start_app_with_params ? 1 : 0) << '\n' << std::flush;
|
||||
|
||||
// The direct GAME path still runs CoreScripts that query app protocols.
|
||||
// Install these independently of ExperienceProtocol's dynamic launch path.
|
||||
if (game_session_runtime != nullptr) {
|
||||
mocktail::runtime::JniEnvironmentProvider environment{
|
||||
raw_vm, jni_vm.get(), &RestoreGameSessionJniEnvironment};
|
||||
JNIEnv* env = nullptr;
|
||||
auto status = environment.Acquire(&env);
|
||||
if (!status.ok()) {
|
||||
std::cerr << "[FATAL] GAME protocol JNI environment unavailable\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const auto symbols = mocktail::runtime::ResolveRobloxPlatformWebSymbols(
|
||||
roblox_handle,
|
||||
reinterpret_cast<mocktail::runtime::SubscribeExperienceLaunchRawFn>(
|
||||
linker::ResolveSymbol(roblox_handle,
|
||||
"Java_com_roblox_universalapp_messagebus_"
|
||||
"MessageBus_doSubscribeRaw")),
|
||||
reinterpret_cast<mocktail::runtime::DeleteMessageBusConnectionFn>(
|
||||
linker::ResolveSymbol(roblox_handle,
|
||||
"Java_com_roblox_universalapp_messagebus_"
|
||||
"Connection_deleteSharedPtr")));
|
||||
jclass bus_class =
|
||||
env->FindClass("com/roblox/universalapp/messagebus/MessageBus");
|
||||
jmethodID singleton =
|
||||
bus_class ? env->GetStaticMethodID(
|
||||
bus_class, "f",
|
||||
"()Lcom/roblox/universalapp/messagebus/MessageBus;")
|
||||
: nullptr;
|
||||
jobject bus = singleton
|
||||
? env->CallStaticObjectMethod(bus_class, singleton)
|
||||
: nullptr;
|
||||
if (bus_class)
|
||||
env->DeleteLocalRef(bus_class);
|
||||
if (!bus || env->ExceptionCheck()) {
|
||||
if (env->ExceptionCheck())
|
||||
env->ExceptionClear();
|
||||
if (bus)
|
||||
env->DeleteLocalRef(bus);
|
||||
std::cerr << "[FATAL] GAME platform MessageBus is unavailable\n";
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
const mocktail::runtime::RobloxMessageBusObjects objects{
|
||||
bus,
|
||||
jni_vm.get(),
|
||||
&CreateExperienceRawCallback,
|
||||
&ClearExperienceRawCallback,
|
||||
&CreateAsyncMessageBusRequestHandler,
|
||||
&ClearAsyncMessageBusRequestHandler};
|
||||
eager_permissions_bridge =
|
||||
std::make_unique<mocktail::runtime::RobloxPermissionsBridge>(
|
||||
environment, symbols.permissions, objects,
|
||||
runtime_config.microphone_enabled());
|
||||
eager_call_protocol_bridge =
|
||||
std::make_unique<mocktail::runtime::RobloxCallProtocolBridge>(
|
||||
environment, symbols.permissions, objects);
|
||||
status = eager_permissions_bridge->Initialize();
|
||||
if (status.ok())
|
||||
status = eager_call_protocol_bridge->Initialize();
|
||||
env->DeleteLocalRef(bus);
|
||||
if (!status.ok()) {
|
||||
std::cerr << "[FATAL] GAME platform protocols did not initialize: "
|
||||
<< status.message() << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
std::cout << " [platform] direct GAME PermissionsProtocol and "
|
||||
"CallProtocol ready\n"
|
||||
<< std::flush;
|
||||
}
|
||||
|
||||
if (experience_game_symbols != nullptr) {
|
||||
mocktail::runtime::RobloxExperienceMessageBusSymbols message_bus_symbols;
|
||||
message_bus_symbols.get_launch_id =
|
||||
@@ -7156,6 +7227,14 @@ int mocktail::legacy::Run(const runtime::CommandLineOptions& options,
|
||||
input_shutdown_completed = window_input_runtime->Shutdown().ok() &&
|
||||
input_shutdown_completed;
|
||||
}
|
||||
if (eager_call_protocol_bridge != nullptr) {
|
||||
(void)eager_call_protocol_bridge->Shutdown();
|
||||
eager_call_protocol_bridge.reset();
|
||||
}
|
||||
if (eager_permissions_bridge != nullptr) {
|
||||
(void)eager_permissions_bridge->Shutdown();
|
||||
eager_permissions_bridge.reset();
|
||||
}
|
||||
bool experience_destroyed_app = false;
|
||||
if (experience_composition != nullptr) {
|
||||
jni_vm->ClearRobloxExperienceLifecycleCallbacks();
|
||||
|
||||
@@ -359,46 +359,43 @@ RobloxThemeCacheResult ReadRobloxThemeCache(
|
||||
return result;
|
||||
}
|
||||
|
||||
const auto read_theme = [&result](const nlohmann::json& value,
|
||||
std::string_view name) {
|
||||
const auto read_theme = [](const nlohmann::json& value) -> std::optional<bool> {
|
||||
if (!value.is_string()) {
|
||||
result.error = std::string(name) + " is not a string";
|
||||
return;
|
||||
return std::nullopt;
|
||||
}
|
||||
const std::string& theme = value.get_ref<const std::string&>();
|
||||
if (theme != "dark" && theme != "light") {
|
||||
result.error = std::string(name) + " must be dark or light";
|
||||
return;
|
||||
if (theme == "dark") {
|
||||
return true;
|
||||
}
|
||||
result.dark_theme = theme == "dark";
|
||||
if (theme == "light") {
|
||||
return false;
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
|
||||
if (authenticated_user_id >= 0) {
|
||||
const auto encoded_device_themes = storage.value.find("DeviceLevelTheme");
|
||||
if (encoded_device_themes != storage.value.end()) {
|
||||
if (!encoded_device_themes->is_string()) {
|
||||
result.error = "Roblox DeviceLevelTheme is not an encoded object";
|
||||
return result;
|
||||
}
|
||||
if (encoded_device_themes != storage.value.end() &&
|
||||
encoded_device_themes->is_string()) {
|
||||
const nlohmann::json device_themes = nlohmann::json::parse(
|
||||
encoded_device_themes->get_ref<const std::string&>(), nullptr, false,
|
||||
true);
|
||||
if (device_themes.is_discarded() || !device_themes.is_object()) {
|
||||
result.error = "Roblox DeviceLevelTheme encoding is invalid";
|
||||
return result;
|
||||
}
|
||||
const auto account_theme =
|
||||
device_themes.find(std::to_string(authenticated_user_id));
|
||||
if (account_theme != device_themes.end()) {
|
||||
read_theme(*account_theme, "Roblox account theme");
|
||||
return result;
|
||||
if (device_themes.is_object()) {
|
||||
const auto account_theme =
|
||||
device_themes.find(std::to_string(authenticated_user_id));
|
||||
if (account_theme != device_themes.end()) {
|
||||
result.dark_theme = read_theme(*account_theme);
|
||||
if (result.dark_theme.has_value()) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const auto authenticated_theme = storage.value.find("AuthenticatedTheme");
|
||||
if (authenticated_theme != storage.value.end()) {
|
||||
read_theme(*authenticated_theme, "Roblox authenticated theme");
|
||||
result.dark_theme = read_theme(*authenticated_theme);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "runtime/roblox_call_protocol_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
|
||||
RobloxCallProtocolBridge::RobloxCallProtocolBridge(
|
||||
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects)
|
||||
: bridge_(environment, symbols, objects, "Call", {"getCallState"},
|
||||
[](JNIEnv* env, jstring request, std::size_t) {
|
||||
// CallProtocolCore registers the wire protocol "Call" (not the
|
||||
// Lua module name "CallProtocol"). Its handler ignores the
|
||||
// payload and returns CallState::serializeCallStateJson. Match
|
||||
// the no-call state; preserve the caller's default-muted
|
||||
// initialization path.
|
||||
if (request && env->GetStringUTFLength(request) > 65536) {
|
||||
return RobloxMessageBusReply{"{}", 13, "oversized_request"};
|
||||
}
|
||||
return RobloxMessageBusReply{
|
||||
R"({"status":"Idle","muted":true,"camEnabled":false})", 0,
|
||||
"idle"};
|
||||
}) {}
|
||||
|
||||
Status RobloxCallProtocolBridge::Initialize() {
|
||||
return bridge_.Initialize();
|
||||
}
|
||||
|
||||
Status RobloxCallProtocolBridge::Shutdown() {
|
||||
return bridge_.Shutdown();
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -138,11 +138,16 @@ Status PublishSystemTheme(JNIEnv* env,
|
||||
status = CheckJni(env, "publish SystemTheme.systemThemeUpdated");
|
||||
}
|
||||
|
||||
if (message_id != nullptr) env->DeleteLocalRef(message_id);
|
||||
if (payload != nullptr) env->DeleteLocalRef(payload);
|
||||
if (message != nullptr) env->DeleteLocalRef(message);
|
||||
if (protocol != nullptr) env->DeleteLocalRef(protocol);
|
||||
if (message_bus_class != nullptr) env->DeleteLocalRef(message_bus_class);
|
||||
if (message_id != nullptr)
|
||||
env->DeleteLocalRef(message_id);
|
||||
if (payload != nullptr)
|
||||
env->DeleteLocalRef(payload);
|
||||
if (message != nullptr)
|
||||
env->DeleteLocalRef(message);
|
||||
if (protocol != nullptr)
|
||||
env->DeleteLocalRef(protocol);
|
||||
if (message_bus_class != nullptr)
|
||||
env->DeleteLocalRef(message_bus_class);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -203,7 +208,8 @@ template <>
|
||||
bool SetField<jint>(JNIEnv* env, jobject object, jclass clazz, const char* name,
|
||||
const char* signature, jint value) {
|
||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||
if (field == nullptr) return false;
|
||||
if (field == nullptr)
|
||||
return false;
|
||||
env->SetIntField(object, field, value);
|
||||
return true;
|
||||
}
|
||||
@@ -212,7 +218,8 @@ template <>
|
||||
bool SetField<jlong>(JNIEnv* env, jobject object, jclass clazz,
|
||||
const char* name, const char* signature, jlong value) {
|
||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||
if (field == nullptr) return false;
|
||||
if (field == nullptr)
|
||||
return false;
|
||||
env->SetLongField(object, field, value);
|
||||
return true;
|
||||
}
|
||||
@@ -222,7 +229,8 @@ bool SetField<jboolean>(JNIEnv* env, jobject object, jclass clazz,
|
||||
const char* name, const char* signature,
|
||||
jboolean value) {
|
||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||
if (field == nullptr) return false;
|
||||
if (field == nullptr)
|
||||
return false;
|
||||
env->SetBooleanField(object, field, value);
|
||||
return true;
|
||||
}
|
||||
@@ -231,7 +239,8 @@ template <>
|
||||
bool SetField<jfloat>(JNIEnv* env, jobject object, jclass clazz,
|
||||
const char* name, const char* signature, jfloat value) {
|
||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||
if (field == nullptr) return false;
|
||||
if (field == nullptr)
|
||||
return false;
|
||||
env->SetFloatField(object, field, value);
|
||||
return true;
|
||||
}
|
||||
@@ -240,7 +249,8 @@ template <>
|
||||
bool SetField<jobject>(JNIEnv* env, jobject object, jclass clazz,
|
||||
const char* name, const char* signature, jobject value) {
|
||||
jfieldID field = env->GetFieldID(clazz, name, signature);
|
||||
if (field == nullptr) return false;
|
||||
if (field == nullptr)
|
||||
return false;
|
||||
env->SetObjectField(object, field, value);
|
||||
return true;
|
||||
}
|
||||
@@ -352,7 +362,9 @@ RobloxExperienceComposition::RobloxExperienceComposition(
|
||||
}
|
||||
}
|
||||
|
||||
RobloxExperienceComposition::~RobloxExperienceComposition() { Shutdown(); }
|
||||
RobloxExperienceComposition::~RobloxExperienceComposition() {
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
if (!web_view_cookie_initialization_error_.empty()) {
|
||||
@@ -366,7 +378,8 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (platform_protocols_initialized_ || objects_ != nullptr ||
|
||||
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr) {
|
||||
web_view_bridge_ != nullptr || permissions_bridge_ != nullptr ||
|
||||
call_protocol_bridge_ != nullptr) {
|
||||
return FailedPrecondition("platform protocols are already initialized");
|
||||
}
|
||||
}
|
||||
@@ -443,6 +456,16 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
(void)ReleaseGlobalObjects();
|
||||
return status;
|
||||
}
|
||||
auto call_protocol_bridge = std::make_unique<RobloxCallProtocolBridge>(
|
||||
environment_, permissions_symbols_, permissions_objects);
|
||||
status = call_protocol_bridge->Initialize();
|
||||
if (!status.ok()) {
|
||||
(void)permissions_bridge->Shutdown();
|
||||
(void)browser_service_bridge->Shutdown();
|
||||
(void)web_view_bridge->Shutdown();
|
||||
(void)ReleaseGlobalObjects();
|
||||
return status;
|
||||
}
|
||||
// AccountProtocol only shortens a device-integrity login challenge that
|
||||
// cannot succeed here, so its absence or failure never blocks startup.
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||
@@ -469,12 +492,13 @@ Status RobloxExperienceComposition::InitializePlatformProtocols() {
|
||||
web_view_bridge_ = std::move(web_view_bridge);
|
||||
browser_service_bridge_ = std::move(browser_service_bridge);
|
||||
permissions_bridge_ = std::move(permissions_bridge);
|
||||
call_protocol_bridge_ = std::move(call_protocol_bridge);
|
||||
account_bridge_ = std::move(account_bridge);
|
||||
platform_protocols_initialized_ = true;
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [platform] Android WebViewProtocol, BrowserService and "
|
||||
"PermissionsProtocol "
|
||||
"PermissionsProtocol and CallProtocol "
|
||||
"bridges ready\n");
|
||||
return Status::Ok();
|
||||
}
|
||||
@@ -494,7 +518,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!platform_protocols_initialized_ || objects_ == nullptr ||
|
||||
objects_->message_bus == nullptr || web_view_bridge_ == nullptr ||
|
||||
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr) {
|
||||
browser_service_bridge_ == nullptr || permissions_bridge_ == nullptr ||
|
||||
call_protocol_bridge_ == nullptr) {
|
||||
return FailedPrecondition(
|
||||
"platform protocols must be initialized before LuaApp readiness");
|
||||
}
|
||||
@@ -549,7 +574,8 @@ Status RobloxExperienceComposition::OnLuaAppReady(
|
||||
controller_.reset();
|
||||
}
|
||||
}
|
||||
if (!status.ok()) (void)ReleaseLuaAppGlobalObjects();
|
||||
if (!status.ok())
|
||||
(void)ReleaseLuaAppGlobalObjects();
|
||||
if (status.ok()) {
|
||||
std::fprintf(stderr,
|
||||
" [theme] published SystemTheme.systemThemeUpdated (%s)\n",
|
||||
@@ -622,8 +648,7 @@ Status RobloxExperienceComposition::OpenWebSurface(
|
||||
!current->SetBackNavigationDisabled(
|
||||
presentation.back_navigation_disabled) ||
|
||||
!current->SetShowDomainAsTitle(presentation.show_domain_as_title) ||
|
||||
!cookie_synchronized ||
|
||||
(!spawn && !current->LoadUrl(url))) {
|
||||
!cookie_synchronized || (!spawn && !current->LoadUrl(url))) {
|
||||
(void)current->RequestClose();
|
||||
return Unavailable("could not configure reusable Roblox web surface");
|
||||
}
|
||||
@@ -1211,10 +1236,10 @@ Status RobloxExperienceComposition::RefreshLateSurface() {
|
||||
SurfaceUpdateStatus(SurfaceCreated(event.surface.generation));
|
||||
break;
|
||||
case window::WindowSurfaceEventType::kChanged:
|
||||
status = SurfaceUpdateStatus(SurfaceChanged(
|
||||
{event.surface.generation, event.surface.native_window,
|
||||
event.surface.width, event.surface.height,
|
||||
event.surface.dpi_scale}));
|
||||
status = SurfaceUpdateStatus(
|
||||
SurfaceChanged({event.surface.generation,
|
||||
event.surface.native_window, event.surface.width,
|
||||
event.surface.height, event.surface.dpi_scale}));
|
||||
break;
|
||||
case window::WindowSurfaceEventType::kDestroyed:
|
||||
status =
|
||||
@@ -1338,14 +1363,16 @@ Status RobloxExperienceComposition::DrainExternalLaunchRequests() {
|
||||
|
||||
void RobloxExperienceComposition::NotifyLuaAppDidReturn() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (subscribed_) lua_app_return_pending_ = true;
|
||||
if (subscribed_)
|
||||
lua_app_return_pending_ = true;
|
||||
}
|
||||
|
||||
Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
Status completed_result = Status::Ok();
|
||||
if (launch_worker_.joinable()) {
|
||||
const OwnedPthreadWaitResult wait = launch_worker_.WaitFor(0, 1);
|
||||
if (wait.status == OwnedPthreadWaitStatus::kTimedOut) return Status::Ok();
|
||||
if (wait.status == OwnedPthreadWaitStatus::kTimedOut)
|
||||
return Status::Ok();
|
||||
if (!wait.joined()) {
|
||||
return Status::Error(StatusCode::kPlatformError,
|
||||
"could not reap experience launch worker: " +
|
||||
@@ -1405,7 +1432,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
}
|
||||
|
||||
Status external_launch_status = DrainExternalLaunchRequests();
|
||||
if (!external_launch_status.ok()) return external_launch_status;
|
||||
if (!external_launch_status.ok())
|
||||
return external_launch_status;
|
||||
|
||||
bool switch_active_game = false;
|
||||
std::optional<RobloxExperienceLaunchRequest> joining_request;
|
||||
@@ -1414,7 +1442,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
||||
return FailedPrecondition("experience composition is not subscribed");
|
||||
}
|
||||
if (controlled_switch_waiting_for_return_) return Status::Ok();
|
||||
if (controlled_switch_waiting_for_return_)
|
||||
return Status::Ok();
|
||||
if (!launch_in_progress_ && active_launch_ == nullptr && game_active_ &&
|
||||
!pending_launch_requests_.empty()) {
|
||||
// Start the queued launch only after consuming this leave callback, so
|
||||
@@ -1439,9 +1468,12 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
if (!subscribed_ || controller_ == nullptr || objects_ == nullptr) {
|
||||
return FailedPrecondition("experience composition is not subscribed");
|
||||
}
|
||||
if (launch_in_progress_ || active_launch_ != nullptr) return Status::Ok();
|
||||
if (game_active_) return Status::Ok();
|
||||
if (pending_launch_requests_.empty()) return Status::Ok();
|
||||
if (launch_in_progress_ || active_launch_ != nullptr)
|
||||
return Status::Ok();
|
||||
if (game_active_)
|
||||
return Status::Ok();
|
||||
if (pending_launch_requests_.empty())
|
||||
return Status::Ok();
|
||||
if (next_request_id_ == 0 ||
|
||||
next_request_id_ == std::numeric_limits<uint64_t>::max()) {
|
||||
pending_launch_requests_.clear();
|
||||
@@ -1477,7 +1509,8 @@ Status RobloxExperienceComposition::DrainLaunchRequests() {
|
||||
const int start_error =
|
||||
launch_worker_.Start(&RobloxExperienceComposition::RunLaunchWorker, this,
|
||||
kLaunchWorkerStackSize);
|
||||
if (start_error == 0) return Status::Ok();
|
||||
if (start_error == 0)
|
||||
return Status::Ok();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
launch_in_progress_ = false;
|
||||
@@ -1534,7 +1567,8 @@ void RobloxExperienceComposition::RunActiveLaunch() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
task = active_launch_.get();
|
||||
}
|
||||
if (task == nullptr) return;
|
||||
if (task == nullptr)
|
||||
return;
|
||||
Status status =
|
||||
BuildLaunchObjects(task->context.game_surface, &task->context.surface,
|
||||
&task->context.platform_params);
|
||||
@@ -1658,6 +1692,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
std::unique_ptr<RobloxWebViewBridge> web_view_bridge;
|
||||
std::unique_ptr<RobloxBrowserServiceBridge> browser_service_bridge;
|
||||
std::unique_ptr<RobloxPermissionsBridge> permissions_bridge;
|
||||
std::unique_ptr<RobloxCallProtocolBridge> call_protocol_bridge;
|
||||
std::unique_ptr<RobloxAccountBridge> account_bridge;
|
||||
std::shared_ptr<WebViewHelperProcess> web_surface_process;
|
||||
jnivm::VM* late_lifecycle_vm = nullptr;
|
||||
@@ -1683,6 +1718,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
web_view_bridge = std::move(web_view_bridge_);
|
||||
browser_service_bridge = std::move(browser_service_bridge_);
|
||||
permissions_bridge = std::move(permissions_bridge_);
|
||||
call_protocol_bridge = std::move(call_protocol_bridge_);
|
||||
account_bridge = std::move(account_bridge_);
|
||||
web_surface_process = std::move(web_surface_process_);
|
||||
web_surface_logical_exit_observer_ = {};
|
||||
@@ -1700,16 +1736,23 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
if (web_surface_process != nullptr) {
|
||||
(void)web_surface_process->RequestClose();
|
||||
}
|
||||
Status status =
|
||||
Status status = call_protocol_bridge != nullptr
|
||||
? call_protocol_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
const Status account_status =
|
||||
account_bridge != nullptr ? account_bridge->Shutdown() : Status::Ok();
|
||||
if (status.ok())
|
||||
status = account_status;
|
||||
const Status permissions_status = permissions_bridge != nullptr
|
||||
? permissions_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
if (status.ok()) status = permissions_status;
|
||||
if (status.ok())
|
||||
status = permissions_status;
|
||||
const Status browser_status = browser_service_bridge != nullptr
|
||||
? browser_service_bridge->Shutdown()
|
||||
: Status::Ok();
|
||||
if (status.ok()) status = browser_status;
|
||||
if (status.ok())
|
||||
status = browser_status;
|
||||
const Status web_view_status =
|
||||
web_view_bridge != nullptr ? web_view_bridge->Shutdown() : Status::Ok();
|
||||
if (status.ok()) {
|
||||
@@ -1721,6 +1764,7 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
status = bridge_status;
|
||||
}
|
||||
browser_service_bridge.reset();
|
||||
call_protocol_bridge.reset();
|
||||
account_bridge.reset();
|
||||
permissions_bridge.reset();
|
||||
web_view_bridge.reset();
|
||||
@@ -1739,14 +1783,16 @@ Status RobloxExperienceComposition::Shutdown() {
|
||||
launch_in_progress_ = false;
|
||||
lua_app_return_pending_ = false;
|
||||
if (active_launch_ != nullptr) {
|
||||
if (status.ok()) status = active_launch_->result;
|
||||
if (status.ok())
|
||||
status = active_launch_->result;
|
||||
active_launch_.reset();
|
||||
}
|
||||
controller = std::move(controller_);
|
||||
}
|
||||
if (controller != nullptr) {
|
||||
const Status controller_status = controller->Shutdown();
|
||||
if (status.ok()) status = controller_status;
|
||||
if (status.ok())
|
||||
status = controller_status;
|
||||
}
|
||||
const Status release = ReleaseGlobalObjects();
|
||||
return status.ok() ? release : status;
|
||||
@@ -1777,7 +1823,8 @@ void RobloxExperienceComposition::NotifyPresence(
|
||||
Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
|
||||
jobject bus_local = nullptr;
|
||||
jclass bus_class =
|
||||
@@ -1791,7 +1838,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||
bus_local = singleton != nullptr
|
||||
? env->CallStaticObjectMethod(bus_class, singleton)
|
||||
: nullptr;
|
||||
if (bus_class != nullptr) env->DeleteLocalRef(bus_class);
|
||||
if (bus_class != nullptr)
|
||||
env->DeleteLocalRef(bus_class);
|
||||
status = bus_local != nullptr
|
||||
? CheckJni(env, "obtain exact MessageBus.f singleton")
|
||||
: Unavailable("MessageBus.f singleton is unavailable");
|
||||
@@ -1804,7 +1852,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||
}
|
||||
}
|
||||
|
||||
if (bus_local != nullptr) env->DeleteLocalRef(bus_local);
|
||||
if (bus_local != nullptr)
|
||||
env->DeleteLocalRef(bus_local);
|
||||
if (!status.ok()) {
|
||||
if (global->message_bus != nullptr)
|
||||
env->DeleteGlobalRef(global->message_bus);
|
||||
@@ -1822,7 +1871,8 @@ Status RobloxExperienceComposition::BuildPlatformGlobalObjects() {
|
||||
Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
|
||||
jclass native_gl_local =
|
||||
env->FindClass("com/roblox/engine/jni/NativeGLInterface");
|
||||
@@ -1846,12 +1896,17 @@ Status RobloxExperienceComposition::BuildLuaAppGlobalObjects() {
|
||||
}
|
||||
}
|
||||
|
||||
if (activity_local != nullptr) env->DeleteLocalRef(activity_local);
|
||||
if (activity_class != nullptr) env->DeleteLocalRef(activity_class);
|
||||
if (native_gl_local != nullptr) env->DeleteLocalRef(native_gl_local);
|
||||
if (activity_local != nullptr)
|
||||
env->DeleteLocalRef(activity_local);
|
||||
if (activity_class != nullptr)
|
||||
env->DeleteLocalRef(activity_class);
|
||||
if (native_gl_local != nullptr)
|
||||
env->DeleteLocalRef(native_gl_local);
|
||||
if (!status.ok()) {
|
||||
if (activity_global != nullptr) env->DeleteGlobalRef(activity_global);
|
||||
if (native_gl_global != nullptr) env->DeleteGlobalRef(native_gl_global);
|
||||
if (activity_global != nullptr)
|
||||
env->DeleteGlobalRef(activity_global);
|
||||
if (native_gl_global != nullptr)
|
||||
env->DeleteGlobalRef(native_gl_global);
|
||||
return status;
|
||||
}
|
||||
|
||||
@@ -1878,7 +1933,8 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
||||
*platform_params = nullptr;
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
|
||||
jclass surface_class = nullptr;
|
||||
jobject surface_local = nullptr;
|
||||
@@ -1912,8 +1968,9 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
||||
SetField<jobject>(env, params_local, params_class, "assetFolderPath",
|
||||
"Ljava/lang/String;", asset_path) &&
|
||||
SetField<jfloat>(env, params_local, params_class, "dpiScale", "F",
|
||||
surface.dpi_scale > 0.0f ? surface.dpi_scale
|
||||
: surface_config_.dpi_scale) &&
|
||||
surface.dpi_scale > 0.0f
|
||||
? surface.dpi_scale
|
||||
: surface_config_.dpi_scale) &&
|
||||
SetField<jint>(env, params_local, params_class, "viewportWidthMm", "I",
|
||||
surface_config_.viewport_width_mm) &&
|
||||
SetField<jint>(env, params_local, params_class, "viewportHeightMm", "I",
|
||||
@@ -1932,12 +1989,17 @@ Status RobloxExperienceComposition::BuildLaunchObjects(
|
||||
? CheckJni(env, "populate fresh launch PlatformParams")
|
||||
: Unavailable("required PlatformParams fields are unavailable");
|
||||
}
|
||||
if (asset_path != nullptr) env->DeleteLocalRef(asset_path);
|
||||
if (surface_class != nullptr) env->DeleteLocalRef(surface_class);
|
||||
if (params_class != nullptr) env->DeleteLocalRef(params_class);
|
||||
if (asset_path != nullptr)
|
||||
env->DeleteLocalRef(asset_path);
|
||||
if (surface_class != nullptr)
|
||||
env->DeleteLocalRef(surface_class);
|
||||
if (params_class != nullptr)
|
||||
env->DeleteLocalRef(params_class);
|
||||
if (!status.ok()) {
|
||||
if (params_local != nullptr) env->DeleteLocalRef(params_local);
|
||||
if (surface_local != nullptr) env->DeleteLocalRef(surface_local);
|
||||
if (params_local != nullptr)
|
||||
env->DeleteLocalRef(params_local);
|
||||
if (surface_local != nullptr)
|
||||
env->DeleteLocalRef(surface_local);
|
||||
return status;
|
||||
}
|
||||
*surface_object = surface_local;
|
||||
@@ -2008,13 +2070,19 @@ Status RobloxExperienceComposition::BuildLuaAppStartParams(
|
||||
"required LuaApp StartAppParams fields are unavailable");
|
||||
}
|
||||
|
||||
if (selected_theme != nullptr) env->DeleteLocalRef(selected_theme);
|
||||
if (username != nullptr) env->DeleteLocalRef(username);
|
||||
if (app_starter_script != nullptr) env->DeleteLocalRef(app_starter_script);
|
||||
if (app_starter_place != nullptr) env->DeleteLocalRef(app_starter_place);
|
||||
if (params_class != nullptr) env->DeleteLocalRef(params_class);
|
||||
if (selected_theme != nullptr)
|
||||
env->DeleteLocalRef(selected_theme);
|
||||
if (username != nullptr)
|
||||
env->DeleteLocalRef(username);
|
||||
if (app_starter_script != nullptr)
|
||||
env->DeleteLocalRef(app_starter_script);
|
||||
if (app_starter_place != nullptr)
|
||||
env->DeleteLocalRef(app_starter_place);
|
||||
if (params_class != nullptr)
|
||||
env->DeleteLocalRef(params_class);
|
||||
if (!status.ok()) {
|
||||
if (params_local != nullptr) env->DeleteLocalRef(params_local);
|
||||
if (params_local != nullptr)
|
||||
env->DeleteLocalRef(params_local);
|
||||
return status;
|
||||
}
|
||||
*start_app_params = params_local;
|
||||
@@ -2046,7 +2114,8 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
|
||||
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
|
||||
jobject surface_object = nullptr;
|
||||
jobject platform_params = nullptr;
|
||||
@@ -2086,11 +2155,16 @@ Status RobloxExperienceComposition::RestartLuaAppSurface(
|
||||
status = CheckJni(env, "restart LuaApp with recreated surface");
|
||||
}
|
||||
|
||||
if (start_reason != nullptr) env->DeleteLocalRef(start_reason);
|
||||
if (stop_reason != nullptr) env->DeleteLocalRef(stop_reason);
|
||||
if (start_app_params != nullptr) env->DeleteLocalRef(start_app_params);
|
||||
if (platform_params != nullptr) env->DeleteLocalRef(platform_params);
|
||||
if (surface_object != nullptr) env->DeleteLocalRef(surface_object);
|
||||
if (start_reason != nullptr)
|
||||
env->DeleteLocalRef(start_reason);
|
||||
if (stop_reason != nullptr)
|
||||
env->DeleteLocalRef(stop_reason);
|
||||
if (start_app_params != nullptr)
|
||||
env->DeleteLocalRef(start_app_params);
|
||||
if (platform_params != nullptr)
|
||||
env->DeleteLocalRef(platform_params);
|
||||
if (surface_object != nullptr)
|
||||
env->DeleteLocalRef(surface_object);
|
||||
if (status.ok()) {
|
||||
std::fprintf(
|
||||
stderr,
|
||||
@@ -2150,7 +2224,8 @@ Status RobloxExperienceComposition::RebindLuaAppSurface(
|
||||
Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
std::unique_ptr<GlobalObjects> objects;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -2165,10 +2240,12 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
||||
pending_launch_requests_.clear();
|
||||
next_request_id_ = 1;
|
||||
}
|
||||
if (objects == nullptr) return Status::Ok();
|
||||
if (objects == nullptr)
|
||||
return Status::Ok();
|
||||
if (objects->message_bus != nullptr)
|
||||
env->DeleteGlobalRef(objects->message_bus);
|
||||
if (objects->activity != nullptr) env->DeleteGlobalRef(objects->activity);
|
||||
if (objects->activity != nullptr)
|
||||
env->DeleteGlobalRef(objects->activity);
|
||||
if (objects->native_gl_class != nullptr)
|
||||
env->DeleteGlobalRef(objects->native_gl_class);
|
||||
return CheckJni(env, "release experience composition JNI roots");
|
||||
@@ -2177,7 +2254,8 @@ Status RobloxExperienceComposition::ReleaseGlobalObjects() {
|
||||
Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
if (!status.ok())
|
||||
return status;
|
||||
|
||||
jclass native_gl_class = nullptr;
|
||||
jobject activity = nullptr;
|
||||
@@ -2197,8 +2275,10 @@ Status RobloxExperienceComposition::ReleaseLuaAppGlobalObjects() {
|
||||
pending_launch_requests_.clear();
|
||||
next_request_id_ = 1;
|
||||
}
|
||||
if (activity != nullptr) env->DeleteGlobalRef(activity);
|
||||
if (native_gl_class != nullptr) env->DeleteGlobalRef(native_gl_class);
|
||||
if (activity != nullptr)
|
||||
env->DeleteGlobalRef(activity);
|
||||
if (native_gl_class != nullptr)
|
||||
env->DeleteGlobalRef(native_gl_class);
|
||||
return CheckJni(env, "release LuaApp experience JNI roots");
|
||||
}
|
||||
|
||||
|
||||
@@ -609,13 +609,7 @@ RobloxInputDispatchResult RobloxInputRouter::HandleMouseButtonLocked(
|
||||
}
|
||||
const float clamped_x = mouse_x_;
|
||||
const float clamped_y = mouse_y_;
|
||||
if (event.pressed) {
|
||||
const RobloxTextEditorSnapshot text = text_editor_.Snapshot();
|
||||
if (text.focused) {
|
||||
(void)text_editor_.EndFocusSession(text.textbox_handle, text.generation,
|
||||
true);
|
||||
}
|
||||
}
|
||||
// Let Roblox decide whether a click releases TextBox focus.
|
||||
Status status = sink_.mouse_button(sink_.context, clamped_x, clamped_y,
|
||||
event.pressed, roblox_button);
|
||||
if (status.ok()) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
// [patch.py] RequestFollowUser support
|
||||
#include "runtime/roblox_presence_resolver.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cctype>
|
||||
@@ -541,14 +544,42 @@ Status ValidateSelector(const LaunchFields& fields) {
|
||||
? Status::Ok()
|
||||
: Invalid("RequestPlayTogetherGame requires a conversation");
|
||||
}
|
||||
// [patch.py] RequestFollowUser support
|
||||
if (request == "requestfollowuser") {
|
||||
// By the time ValidateSelector runs, MakeRequest has already
|
||||
// resolved user_id -> place_id and cleared user_id, so check the
|
||||
// resolved place_id here rather than the now-zeroed user_id.
|
||||
return fields.place_id > 0
|
||||
? Status::Ok()
|
||||
: Invalid("RequestFollowUser could not resolve a place");
|
||||
}
|
||||
return Invalid("Roblox PlaceLauncher request type is unsupported");
|
||||
}
|
||||
|
||||
Status MakeRequest(const LaunchFields& fields,
|
||||
// [patch.py] RequestFollowUser support
|
||||
Status MakeRequest(const LaunchFields& fields_in,
|
||||
RobloxExperienceLaunchRequest* request) {
|
||||
if (fields.place_id <= 0) {
|
||||
LaunchFields fields = fields_in;
|
||||
const bool is_follow_user =
|
||||
AsciiLower(fields.request_type) == "requestfollowuser";
|
||||
if (fields.place_id <= 0 && !is_follow_user) {
|
||||
return Invalid("Roblox launch URI requires a positive placeId");
|
||||
}
|
||||
if (fields.place_id <= 0 && is_follow_user) {
|
||||
ResolvedFollowUserPlace resolved;
|
||||
const Status resolve_status = ResolveFollowUserPlace(
|
||||
fields.user_id, ReadStoredRoblosecurityCookie(), &resolved);
|
||||
if (!resolve_status.ok()) {
|
||||
return resolve_status;
|
||||
}
|
||||
fields.place_id = resolved.place_id;
|
||||
if (!resolved.game_instance_id.empty()) {
|
||||
fields.game_instance_id = resolved.game_instance_id;
|
||||
}
|
||||
// user_id was only needed to resolve which place to join; clear
|
||||
// it so ValidateSelector does not see two conflicting selectors.
|
||||
fields.user_id = 0;
|
||||
}
|
||||
Status status = ValidateSelector(fields);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
#include "runtime/roblox_message_bus_request_bridge.h"
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
Status Unavailable(const char* message) {
|
||||
return Status::Error(StatusCode::kUnavailable, message);
|
||||
}
|
||||
|
||||
Status CheckJni(JNIEnv* env) {
|
||||
if (!env->ExceptionCheck())
|
||||
return Status::Ok();
|
||||
env->ExceptionClear();
|
||||
return Unavailable("MessageBus JNI operation failed");
|
||||
}
|
||||
|
||||
// JNI local references are never retained across callback invocations.
|
||||
struct LocalString {
|
||||
JNIEnv* env;
|
||||
jstring value;
|
||||
|
||||
LocalString(JNIEnv* source, const char* text)
|
||||
: env(source), value(source->NewStringUTF(text)) {}
|
||||
|
||||
~LocalString() {
|
||||
if (value)
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
struct RobloxMessageBusRequestBridge::State {
|
||||
struct Endpoint {
|
||||
std::shared_ptr<State> state;
|
||||
std::size_t method;
|
||||
};
|
||||
|
||||
struct Registration {
|
||||
jobject callback = nullptr;
|
||||
jobject handler = nullptr;
|
||||
jobject connection = nullptr;
|
||||
jlong connection_handle = 0;
|
||||
jstring method = nullptr;
|
||||
bool handler_registered = false;
|
||||
};
|
||||
|
||||
RobloxMessageBusSymbols symbols;
|
||||
jobject bus = nullptr;
|
||||
jstring protocol = nullptr;
|
||||
std::string protocol_name;
|
||||
std::vector<std::string> methods;
|
||||
Handler handler;
|
||||
std::vector<Registration> registrations;
|
||||
std::mutex mutex;
|
||||
std::condition_variable drained;
|
||||
bool accepting = true;
|
||||
std::size_t in_flight = 0;
|
||||
|
||||
static void Legacy(void* context, JNIEnv* env, jstring message) {
|
||||
Dispatch(context, env, message, nullptr);
|
||||
}
|
||||
|
||||
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
|
||||
Dispatch(context, env, message, id);
|
||||
}
|
||||
|
||||
static void Dispatch(void* context, JNIEnv* env, jstring message,
|
||||
jstring id) {
|
||||
const auto* endpoint = static_cast<Endpoint*>(context);
|
||||
const auto state = endpoint->state;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->accepting)
|
||||
return;
|
||||
++state->in_flight;
|
||||
}
|
||||
|
||||
struct Finish {
|
||||
std::shared_ptr<State> state;
|
||||
|
||||
~Finish() {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
--state->in_flight;
|
||||
state->drained.notify_all();
|
||||
}
|
||||
} finish{state};
|
||||
|
||||
const auto response = state->handler(env, message, endpoint->method);
|
||||
// Request bodies and correlation IDs must never be logged.
|
||||
std::fprintf(stderr, " [messagebus] %s method=%s result=%s transport=%s\n",
|
||||
state->protocol_name.c_str(),
|
||||
state->methods[endpoint->method].c_str(), response.outcome,
|
||||
id ? "async" : "legacy");
|
||||
LocalString body(env, response.json.c_str());
|
||||
LocalString telemetry(env, "{}");
|
||||
LocalString method(env, state->methods[endpoint->method].c_str());
|
||||
const Status allocated = CheckJni(env);
|
||||
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
|
||||
std::fprintf(stderr, " [messagebus] response allocation failed\n");
|
||||
return;
|
||||
}
|
||||
// The APK publishes the legacy response even for the newer async API,
|
||||
// then resolves the individual request by its opaque response ID.
|
||||
state->symbols.publish_response(env, state->bus, state->protocol,
|
||||
method.value, body.value, response.code,
|
||||
telemetry.value);
|
||||
const Status published = CheckJni(env);
|
||||
if (id) {
|
||||
state->symbols.call_response_handler(env, state->bus, id, body.value);
|
||||
}
|
||||
if (!CheckJni(env).ok() || !published.ok()) {
|
||||
std::fprintf(stderr, " [messagebus] response delivery failed\n");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RobloxMessageBusRequestBridge::RobloxMessageBusRequestBridge(
|
||||
JniEnvironmentProvider environment, RobloxMessageBusSymbols symbols,
|
||||
RobloxMessageBusObjects objects, std::string protocol,
|
||||
std::vector<std::string> methods, Handler handler)
|
||||
: environment_(environment),
|
||||
symbols_(symbols),
|
||||
objects_(objects),
|
||||
protocol_name_(std::move(protocol)),
|
||||
methods_(std::move(methods)),
|
||||
handler_(std::move(handler)) {}
|
||||
|
||||
RobloxMessageBusRequestBridge::~RobloxMessageBusRequestBridge() {
|
||||
(void)Shutdown();
|
||||
}
|
||||
|
||||
Status RobloxMessageBusRequestBridge::Initialize() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
if (state_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition,
|
||||
"MessageBus request bridge is already initialized");
|
||||
}
|
||||
if (!environment_.valid() || !symbols_.complete() || !objects_.complete() ||
|
||||
protocol_name_.empty() || methods_.empty() || methods_.size() > 64 ||
|
||||
!handler_) {
|
||||
return Unavailable(
|
||||
"MessageBus request bridge prerequisites are incomplete");
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok())
|
||||
return status;
|
||||
state_ = std::make_shared<State>();
|
||||
state_->symbols = symbols_;
|
||||
state_->protocol_name = protocol_name_;
|
||||
state_->methods = methods_;
|
||||
state_->handler = handler_;
|
||||
state_->registrations.resize(methods_.size());
|
||||
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
||||
LocalString protocol(env, protocol_name_.c_str());
|
||||
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
|
||||
auto fail = [this](Status failure) {
|
||||
(void)ShutdownLocked();
|
||||
return failure;
|
||||
};
|
||||
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
|
||||
return fail(
|
||||
Unavailable("could not retain MessageBus request bridge roots"));
|
||||
}
|
||||
for (std::size_t i = 0; i < methods_.size(); ++i) {
|
||||
auto& entry = state_->registrations[i];
|
||||
auto endpoint =
|
||||
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
|
||||
LocalString method(env, methods_[i].c_str());
|
||||
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
|
||||
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
|
||||
&State::Legacy);
|
||||
if (callback) {
|
||||
entry.callback = env->NewGlobalRef(callback);
|
||||
if (!entry.callback)
|
||||
objects_.clear_raw_callback(objects_.context, callback);
|
||||
env->DeleteLocalRef(callback);
|
||||
}
|
||||
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
|
||||
&State::Async);
|
||||
if (handler) {
|
||||
entry.handler = env->NewGlobalRef(handler);
|
||||
if (!entry.handler)
|
||||
objects_.clear_async_handler(objects_.context, handler);
|
||||
env->DeleteLocalRef(handler);
|
||||
}
|
||||
if (!entry.method || !entry.callback || !entry.handler ||
|
||||
!CheckJni(env).ok()) {
|
||||
return fail(
|
||||
Unavailable("could not create MessageBus request bridge callbacks"));
|
||||
}
|
||||
jobject connection =
|
||||
symbols_.subscribe_request(env, state_->bus, state_->protocol,
|
||||
entry.method, entry.callback, JNI_FALSE);
|
||||
if (connection) {
|
||||
jclass cls = env->GetObjectClass(connection);
|
||||
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
|
||||
if (pointer)
|
||||
entry.connection_handle = env->GetLongField(connection, pointer);
|
||||
if (cls)
|
||||
env->DeleteLocalRef(cls);
|
||||
entry.connection = env->NewGlobalRef(connection);
|
||||
if (!entry.connection && entry.connection_handle) {
|
||||
symbols_.delete_connection(env, connection, entry.connection_handle);
|
||||
entry.connection_handle = 0;
|
||||
}
|
||||
env->DeleteLocalRef(connection);
|
||||
}
|
||||
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
|
||||
return fail(
|
||||
Unavailable("could not subscribe MessageBus request bridge request"));
|
||||
}
|
||||
entry.handler_registered = true;
|
||||
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
|
||||
entry.handler);
|
||||
status = CheckJni(env);
|
||||
if (!status.ok())
|
||||
return fail(status);
|
||||
}
|
||||
std::fprintf(stderr, " [messagebus] %s ready legacy=%zu async=%zu\n",
|
||||
protocol_name_.c_str(), methods_.size(), methods_.size());
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
Status RobloxMessageBusRequestBridge::Shutdown() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
return ShutdownLocked();
|
||||
}
|
||||
|
||||
Status RobloxMessageBusRequestBridge::ShutdownLocked() {
|
||||
if (!state_)
|
||||
return Status::Ok();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state_->mutex);
|
||||
state_->accepting = false;
|
||||
state_->drained.wait(lock, [this] {
|
||||
return state_->in_flight == 0;
|
||||
});
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok())
|
||||
return status; // Keep roots for a later shutdown retry.
|
||||
// Clear any exception from partially failed initialization before cleanup.
|
||||
const Status pending = CheckJni(env);
|
||||
if (!pending.ok())
|
||||
status = pending;
|
||||
for (auto& entry : state_->registrations) {
|
||||
if (entry.handler_registered) {
|
||||
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok())
|
||||
status = cleared;
|
||||
entry.handler_registered = false;
|
||||
}
|
||||
if (entry.connection && entry.connection_handle) {
|
||||
symbols_.delete_connection(env, entry.connection,
|
||||
entry.connection_handle);
|
||||
const Status disconnected = CheckJni(env);
|
||||
if (!disconnected.ok())
|
||||
status = disconnected;
|
||||
entry.connection_handle = 0;
|
||||
}
|
||||
if (entry.callback) {
|
||||
objects_.clear_raw_callback(objects_.context, entry.callback);
|
||||
env->DeleteGlobalRef(entry.callback);
|
||||
}
|
||||
if (entry.handler) {
|
||||
objects_.clear_async_handler(objects_.context, entry.handler);
|
||||
env->DeleteGlobalRef(entry.handler);
|
||||
}
|
||||
if (entry.connection)
|
||||
env->DeleteGlobalRef(entry.connection);
|
||||
if (entry.method)
|
||||
env->DeleteGlobalRef(entry.method);
|
||||
entry = {};
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok())
|
||||
status = cleared;
|
||||
}
|
||||
if (state_->protocol)
|
||||
env->DeleteGlobalRef(state_->protocol);
|
||||
if (state_->bus)
|
||||
env->DeleteGlobalRef(state_->bus);
|
||||
state_.reset();
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -1,46 +1,14 @@
|
||||
#include "runtime/roblox_permissions_bridge.h"
|
||||
|
||||
#include <array>
|
||||
#include <condition_variable>
|
||||
#include <cstdio>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
constexpr char kProtocol[] = "PermissionsProtocol";
|
||||
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
|
||||
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
|
||||
constexpr const char* kMethods[] = {
|
||||
"SupportsPermissions", "HasPermissions", "PermissionsRequest",
|
||||
"ShouldShowPermissionUpsell", "ShouldShowRequestPermissionRationale"};
|
||||
constexpr std::size_t kMethodCount = std::size(kMethods);
|
||||
constexpr std::size_t kMaximumJsonBytes = 64 * 1024;
|
||||
constexpr std::size_t kMaximumFeatures = 64;
|
||||
|
||||
Status Unavailable(const char* message) {
|
||||
return Status::Error(StatusCode::kUnavailable, message);
|
||||
}
|
||||
|
||||
Status CheckJni(JNIEnv* env) {
|
||||
if (!env->ExceptionCheck()) return Status::Ok();
|
||||
env->ExceptionClear();
|
||||
return Unavailable("PermissionsProtocol JNI operation failed");
|
||||
}
|
||||
|
||||
// JNI local references are never retained across callback invocations.
|
||||
struct LocalString {
|
||||
JNIEnv* env;
|
||||
jstring value;
|
||||
LocalString(JNIEnv* source, const char* text)
|
||||
: env(source), value(source->NewStringUTF(text)) {}
|
||||
~LocalString() {
|
||||
if (value) env->DeleteLocalRef(value);
|
||||
}
|
||||
};
|
||||
constexpr char kMicrophone[] = "MICROPHONE_ACCESS";
|
||||
constexpr char kLocalNetwork[] = "LOCAL_NETWORK";
|
||||
|
||||
struct Response {
|
||||
nlohmann::json body;
|
||||
@@ -63,7 +31,8 @@ bool HasBoundedDepth(const std::string& json) {
|
||||
} else if (ch == '"') {
|
||||
quoted = true;
|
||||
} else if (ch == '{' || ch == '[') {
|
||||
if (++depth > 8) return false;
|
||||
if (++depth > 8)
|
||||
return false;
|
||||
} else if (ch == '}' || ch == ']') {
|
||||
--depth;
|
||||
}
|
||||
@@ -82,18 +51,22 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
||||
: nlohmann::json{{"status", "DENIED"},
|
||||
{"missingPermissions", {kMicrophone}}},
|
||||
13, "invalid_request"};
|
||||
if (!message) return rejected;
|
||||
if (!message)
|
||||
return rejected;
|
||||
const jsize size = env->GetStringUTFLength(message);
|
||||
if (size <= 0 || static_cast<std::size_t>(size) > kMaximumJsonBytes) {
|
||||
return rejected;
|
||||
}
|
||||
const char* bytes = env->GetStringUTFChars(message, nullptr);
|
||||
if (!bytes) return rejected;
|
||||
if (!bytes)
|
||||
return rejected;
|
||||
const std::string owned(bytes, static_cast<std::size_t>(size));
|
||||
env->ReleaseStringUTFChars(message, bytes);
|
||||
if (!HasBoundedDepth(owned)) return rejected;
|
||||
if (!HasBoundedDepth(owned))
|
||||
return rejected;
|
||||
const auto request = nlohmann::json::parse(owned, nullptr, false);
|
||||
if (!request.is_object()) return rejected;
|
||||
if (!request.is_object())
|
||||
return rejected;
|
||||
if (support) {
|
||||
return {{{"permissions", {kMicrophone, kLocalNetwork}}}, 0, "supported"};
|
||||
}
|
||||
@@ -104,7 +77,8 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
||||
}
|
||||
auto missing = nlohmann::json::array();
|
||||
for (const auto& feature : *features) {
|
||||
if (!feature.is_string()) return rejected;
|
||||
if (!feature.is_string())
|
||||
return rejected;
|
||||
const auto& name = feature.get_ref<const std::string&>();
|
||||
if (name.empty() || name.size() > 128 ||
|
||||
name.find_first_of("\r\n\t") != std::string::npos ||
|
||||
@@ -132,233 +106,31 @@ Response Answer(JNIEnv* env, jstring message, std::size_t method,
|
||||
|
||||
} // namespace
|
||||
|
||||
struct RobloxPermissionsBridge::State {
|
||||
struct Endpoint {
|
||||
std::shared_ptr<State> state;
|
||||
std::size_t method;
|
||||
};
|
||||
struct Registration {
|
||||
jobject callback = nullptr;
|
||||
jobject handler = nullptr;
|
||||
jobject connection = nullptr;
|
||||
jlong connection_handle = 0;
|
||||
jstring method = nullptr;
|
||||
bool handler_registered = false;
|
||||
};
|
||||
|
||||
RobloxPermissionsMessageBusSymbols symbols;
|
||||
jobject bus = nullptr;
|
||||
jstring protocol = nullptr;
|
||||
bool microphone_enabled = false;
|
||||
std::array<Registration, kMethodCount> registrations;
|
||||
std::mutex mutex;
|
||||
std::condition_variable drained;
|
||||
bool accepting = true;
|
||||
std::size_t in_flight = 0;
|
||||
|
||||
static void Legacy(void* context, JNIEnv* env, jstring message) {
|
||||
Dispatch(context, env, message, nullptr);
|
||||
}
|
||||
static void Async(void* context, JNIEnv* env, jstring message, jstring id) {
|
||||
Dispatch(context, env, message, id);
|
||||
}
|
||||
static void Dispatch(void* context, JNIEnv* env, jstring message,
|
||||
jstring id) {
|
||||
const auto* endpoint = static_cast<Endpoint*>(context);
|
||||
const auto state = endpoint->state;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
if (!state->accepting) return;
|
||||
++state->in_flight;
|
||||
}
|
||||
struct Finish {
|
||||
std::shared_ptr<State> state;
|
||||
~Finish() {
|
||||
std::lock_guard<std::mutex> lock(state->mutex);
|
||||
--state->in_flight;
|
||||
state->drained.notify_all();
|
||||
}
|
||||
} finish{state};
|
||||
|
||||
Response response =
|
||||
Answer(env, message, endpoint->method, state->microphone_enabled);
|
||||
// No payload, feature strings, or correlation IDs in logs.
|
||||
std::fprintf(stderr,
|
||||
" [permissions] %s method=%s result=%s microphone=%s "
|
||||
"transport=%s\n",
|
||||
kProtocol, kMethods[endpoint->method], response.outcome,
|
||||
state->microphone_enabled ? "enabled" : "disabled",
|
||||
id ? "async" : "legacy");
|
||||
LocalString body(env, response.body.dump().c_str());
|
||||
LocalString telemetry(env, "{}");
|
||||
LocalString method(env, kMethods[endpoint->method]);
|
||||
const Status allocated = CheckJni(env);
|
||||
if (!body.value || !telemetry.value || !method.value || !allocated.ok()) {
|
||||
std::fprintf(stderr, " [permissions] response allocation failed\n");
|
||||
return;
|
||||
}
|
||||
// The APK publishes the legacy response even for the newer async API,
|
||||
// then resolves the individual request by its opaque response ID.
|
||||
state->symbols.publish_response(env, state->bus, state->protocol,
|
||||
method.value, body.value, response.code,
|
||||
telemetry.value);
|
||||
const Status published = CheckJni(env);
|
||||
if (id) {
|
||||
state->symbols.call_response_handler(env, state->bus, id, body.value);
|
||||
}
|
||||
if (!CheckJni(env).ok() || !published.ok()) {
|
||||
std::fprintf(stderr, " [permissions] response delivery failed\n");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
RobloxPermissionsBridge::RobloxPermissionsBridge(
|
||||
JniEnvironmentProvider environment,
|
||||
RobloxPermissionsMessageBusSymbols symbols,
|
||||
RobloxPermissionsMessageBusObjects objects, bool microphone_enabled)
|
||||
: environment_(environment),
|
||||
symbols_(symbols),
|
||||
objects_(objects),
|
||||
microphone_enabled_(microphone_enabled) {}
|
||||
: bridge_(std::make_unique<RobloxMessageBusRequestBridge>(
|
||||
environment, symbols, objects, "PermissionsProtocol",
|
||||
std::vector<std::string>{"SupportsPermissions", "HasPermissions",
|
||||
"PermissionsRequest",
|
||||
"ShouldShowPermissionUpsell",
|
||||
"ShouldShowRequestPermissionRationale"},
|
||||
[microphone_enabled](JNIEnv* env, jstring message,
|
||||
std::size_t method) {
|
||||
auto response = Answer(env, message, method, microphone_enabled);
|
||||
return RobloxMessageBusReply{response.body.dump(), response.code,
|
||||
response.outcome};
|
||||
})) {}
|
||||
|
||||
RobloxPermissionsBridge::~RobloxPermissionsBridge() { (void)Shutdown(); }
|
||||
RobloxPermissionsBridge::~RobloxPermissionsBridge() = default;
|
||||
|
||||
Status RobloxPermissionsBridge::Initialize() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
if (state_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition,
|
||||
"PermissionsProtocol is already initialized");
|
||||
}
|
||||
if (!environment_.valid() || !symbols_.complete() || !objects_.complete()) {
|
||||
return Unavailable("PermissionsProtocol prerequisites are incomplete");
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status;
|
||||
state_ = std::make_shared<State>();
|
||||
state_->symbols = symbols_;
|
||||
state_->microphone_enabled = microphone_enabled_;
|
||||
state_->bus = env->NewGlobalRef(objects_.message_bus);
|
||||
LocalString protocol(env, kProtocol);
|
||||
state_->protocol = static_cast<jstring>(env->NewGlobalRef(protocol.value));
|
||||
auto fail = [this](Status failure) {
|
||||
(void)ShutdownLocked();
|
||||
return failure;
|
||||
};
|
||||
if (!state_->bus || !state_->protocol || !CheckJni(env).ok()) {
|
||||
return fail(Unavailable("could not retain PermissionsProtocol roots"));
|
||||
}
|
||||
for (std::size_t i = 0; i < kMethodCount; ++i) {
|
||||
auto& entry = state_->registrations[i];
|
||||
auto endpoint =
|
||||
std::make_shared<State::Endpoint>(State::Endpoint{state_, i});
|
||||
LocalString method(env, kMethods[i]);
|
||||
entry.method = static_cast<jstring>(env->NewGlobalRef(method.value));
|
||||
jobject callback = objects_.create_raw_callback(objects_.context, endpoint,
|
||||
&State::Legacy);
|
||||
if (callback) {
|
||||
entry.callback = env->NewGlobalRef(callback);
|
||||
if (!entry.callback)
|
||||
objects_.clear_raw_callback(objects_.context, callback);
|
||||
env->DeleteLocalRef(callback);
|
||||
}
|
||||
jobject handler = objects_.create_async_handler(objects_.context, endpoint,
|
||||
&State::Async);
|
||||
if (handler) {
|
||||
entry.handler = env->NewGlobalRef(handler);
|
||||
if (!entry.handler)
|
||||
objects_.clear_async_handler(objects_.context, handler);
|
||||
env->DeleteLocalRef(handler);
|
||||
}
|
||||
if (!entry.method || !entry.callback || !entry.handler ||
|
||||
!CheckJni(env).ok()) {
|
||||
return fail(
|
||||
Unavailable("could not create PermissionsProtocol callbacks"));
|
||||
}
|
||||
jobject connection =
|
||||
symbols_.subscribe_request(env, state_->bus, state_->protocol,
|
||||
entry.method, entry.callback, JNI_FALSE);
|
||||
if (connection) {
|
||||
jclass cls = env->GetObjectClass(connection);
|
||||
jfieldID pointer = cls ? env->GetFieldID(cls, "a", "J") : nullptr;
|
||||
if (pointer)
|
||||
entry.connection_handle = env->GetLongField(connection, pointer);
|
||||
if (cls) env->DeleteLocalRef(cls);
|
||||
entry.connection = env->NewGlobalRef(connection);
|
||||
if (!entry.connection && entry.connection_handle) {
|
||||
symbols_.delete_connection(env, connection, entry.connection_handle);
|
||||
entry.connection_handle = 0;
|
||||
}
|
||||
env->DeleteLocalRef(connection);
|
||||
}
|
||||
if (!entry.connection || !entry.connection_handle || !CheckJni(env).ok()) {
|
||||
return fail(
|
||||
Unavailable("could not subscribe PermissionsProtocol request"));
|
||||
}
|
||||
entry.handler_registered = true;
|
||||
symbols_.set_async_handler(env, state_->bus, state_->protocol, entry.method,
|
||||
entry.handler);
|
||||
status = CheckJni(env);
|
||||
if (!status.ok()) return fail(status);
|
||||
}
|
||||
std::fprintf(stderr,
|
||||
" [permissions] PermissionsProtocol ready microphone=%s "
|
||||
"legacy=5 async=5\n",
|
||||
microphone_enabled_ ? "enabled" : "disabled");
|
||||
return Status::Ok();
|
||||
return bridge_->Initialize();
|
||||
}
|
||||
|
||||
Status RobloxPermissionsBridge::Shutdown() {
|
||||
std::lock_guard<std::mutex> lifecycle(lifecycle_mutex_);
|
||||
return ShutdownLocked();
|
||||
return bridge_->Shutdown();
|
||||
}
|
||||
|
||||
Status RobloxPermissionsBridge::ShutdownLocked() {
|
||||
if (!state_) return Status::Ok();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state_->mutex);
|
||||
state_->accepting = false;
|
||||
state_->drained.wait(lock, [this] { return state_->in_flight == 0; });
|
||||
}
|
||||
JNIEnv* env = nullptr;
|
||||
Status status = environment_.Acquire(&env);
|
||||
if (!status.ok()) return status; // Keep roots for a later shutdown retry.
|
||||
// Clear any exception from partially failed initialization before cleanup.
|
||||
const Status pending = CheckJni(env);
|
||||
if (!pending.ok()) status = pending;
|
||||
for (auto& entry : state_->registrations) {
|
||||
if (entry.handler_registered) {
|
||||
symbols_.clear_handler(env, state_->bus, state_->protocol, entry.method);
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok()) status = cleared;
|
||||
entry.handler_registered = false;
|
||||
}
|
||||
if (entry.connection && entry.connection_handle) {
|
||||
symbols_.delete_connection(env, entry.connection,
|
||||
entry.connection_handle);
|
||||
const Status disconnected = CheckJni(env);
|
||||
if (!disconnected.ok()) status = disconnected;
|
||||
entry.connection_handle = 0;
|
||||
}
|
||||
if (entry.callback) {
|
||||
objects_.clear_raw_callback(objects_.context, entry.callback);
|
||||
env->DeleteGlobalRef(entry.callback);
|
||||
}
|
||||
if (entry.handler) {
|
||||
objects_.clear_async_handler(objects_.context, entry.handler);
|
||||
env->DeleteGlobalRef(entry.handler);
|
||||
}
|
||||
if (entry.connection) env->DeleteGlobalRef(entry.connection);
|
||||
if (entry.method) env->DeleteGlobalRef(entry.method);
|
||||
entry = {};
|
||||
const Status cleared = CheckJni(env);
|
||||
if (!cleared.ok()) status = cleared;
|
||||
}
|
||||
if (state_->protocol) env->DeleteGlobalRef(state_->protocol);
|
||||
if (state_->bus) env->DeleteGlobalRef(state_->bus);
|
||||
state_.reset();
|
||||
return status;
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -0,0 +1,141 @@
|
||||
#include "runtime/roblox_presence_resolver.h"
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
namespace {
|
||||
|
||||
Status Invalid(std::string message) {
|
||||
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
||||
}
|
||||
|
||||
size_t WriteToString(char* data, size_t size, size_t nmemb, void* userdata) {
|
||||
static_cast<std::string*>(userdata)->append(data, size * nmemb);
|
||||
return size * nmemb;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
// Reads mocktail's own persisted session cookie from
|
||||
// $XDG_DATA_HOME/mocktail/auth/roblox.cookie (default
|
||||
// ~/.local/share/mocktail/auth/roblox.cookie), matching the format
|
||||
// mocktail's own auth code already writes there: a single line of the
|
||||
// form ".ROBLOSECURITY=<value>". Strips the prefix so callers get just
|
||||
// the bare value. Returns an empty string if no session is stored yet.
|
||||
std::string ReadStoredRoblosecurityCookie() {
|
||||
const char* home = std::getenv("HOME");
|
||||
if (home == nullptr || home[0] == '\0') {
|
||||
return std::string();
|
||||
}
|
||||
const char* xdg_data_home = std::getenv("XDG_DATA_HOME");
|
||||
const std::string base =
|
||||
(xdg_data_home != nullptr && xdg_data_home[0] != '\0')
|
||||
? std::string(xdg_data_home)
|
||||
: std::string(home) + "/.local/share";
|
||||
const std::string path = base + "/mocktail/auth/roblox.cookie";
|
||||
|
||||
std::ifstream file(path);
|
||||
if (!file.is_open()) {
|
||||
return std::string();
|
||||
}
|
||||
std::string line;
|
||||
std::getline(file, line);
|
||||
while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
|
||||
line.pop_back();
|
||||
}
|
||||
constexpr std::string_view kPrefix = ".ROBLOSECURITY=";
|
||||
if (line.size() > kPrefix.size() &&
|
||||
line.compare(0, kPrefix.size(), kPrefix) == 0) {
|
||||
return line.substr(kPrefix.size());
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
Status ResolveFollowUserPlace(int64_t user_id, const std::string& auth_cookie,
|
||||
ResolvedFollowUserPlace* out) {
|
||||
if (out == nullptr || user_id <= 0) {
|
||||
return Invalid("Roblox presence lookup requires a positive userId");
|
||||
}
|
||||
if (auth_cookie.empty()) {
|
||||
return Invalid("Roblox presence lookup requires an authenticated session");
|
||||
}
|
||||
|
||||
CURL* curl = curl_easy_init();
|
||||
if (curl == nullptr) {
|
||||
return Invalid("Roblox presence lookup could not initialize a request");
|
||||
}
|
||||
|
||||
const nlohmann::json body = {{"userIds", {user_id}}};
|
||||
const std::string payload = body.dump();
|
||||
std::string response;
|
||||
|
||||
struct curl_slist* headers = nullptr;
|
||||
headers = curl_slist_append(headers, "Content-Type: application/json");
|
||||
const std::string cookie_header = "Cookie: .ROBLOSECURITY=" + auth_cookie;
|
||||
headers = curl_slist_append(headers, cookie_header.c_str());
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL,
|
||||
"https://presence.roblox.com/v1/presence/users");
|
||||
curl_easy_setopt(curl, CURLOPT_POST, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, payload.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteToString);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L);
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||
|
||||
const CURLcode result = curl_easy_perform(curl);
|
||||
long http_status = 0;
|
||||
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_status);
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
|
||||
if (result != CURLE_OK) {
|
||||
return Invalid("Roblox presence lookup failed to connect");
|
||||
}
|
||||
if (http_status != 200) {
|
||||
return Invalid("Roblox presence lookup was rejected (status " +
|
||||
std::to_string(http_status) + ")");
|
||||
}
|
||||
|
||||
const nlohmann::json parsed = nlohmann::json::parse(
|
||||
response, /*cb=*/nullptr, /*allow_exceptions=*/false);
|
||||
if (parsed.is_discarded()) {
|
||||
return Invalid("Roblox presence lookup returned malformed JSON");
|
||||
}
|
||||
|
||||
if (!parsed.contains("userPresences") ||
|
||||
!parsed["userPresences"].is_array() ||
|
||||
parsed["userPresences"].empty()) {
|
||||
return Invalid("Roblox presence lookup returned no data for user");
|
||||
}
|
||||
|
||||
const auto& presence = parsed["userPresences"][0];
|
||||
// userPresenceType: 0 offline, 1 online, 2 in-game, 3 in-studio.
|
||||
const int presence_type = presence.value("userPresenceType", 0);
|
||||
if (presence_type != 2) {
|
||||
return Invalid("User is not currently in a joinable Roblox experience");
|
||||
}
|
||||
if (!presence.contains("placeId") || presence["placeId"].is_null()) {
|
||||
return Invalid("Roblox presence lookup did not include a placeId");
|
||||
}
|
||||
|
||||
out->place_id = presence.value("placeId", int64_t{0});
|
||||
if (presence.contains("gameId") && !presence["gameId"].is_null()) {
|
||||
out->game_instance_id = presence.value("gameId", std::string());
|
||||
}
|
||||
if (out->place_id <= 0) {
|
||||
return Invalid("Roblox presence lookup returned an invalid placeId");
|
||||
}
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -1,6 +1,9 @@
|
||||
#include "runtime/webview_helper_launcher.h"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <curl/curl.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <fcntl.h>
|
||||
#include <poll.h>
|
||||
#include <pthread.h>
|
||||
@@ -21,6 +24,7 @@
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <new>
|
||||
@@ -38,6 +42,8 @@ struct WebViewHelperProcess::State {
|
||||
std::mutex control_mutex;
|
||||
int control_descriptor = -1;
|
||||
std::deque<WebViewHelperEvent> pending_events;
|
||||
std::string receive_buffer;
|
||||
bool tcp = false;
|
||||
bool ready = false;
|
||||
|
||||
~State() {
|
||||
@@ -67,7 +73,36 @@ class SensitiveStringGuard final {
|
||||
WebViewHelperProcess::WebViewHelperProcess(std::shared_ptr<State> state)
|
||||
: state_(std::move(state)) {}
|
||||
|
||||
bool WebViewHelperProcess::running() const { return process_id() > 0; }
|
||||
bool WebViewHelperProcess::running() const {
|
||||
if (state_ == nullptr) {
|
||||
return false;
|
||||
}
|
||||
if (!state_->tcp) {
|
||||
return process_id() > 0;
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state_->control_mutex);
|
||||
if (state_->control_descriptor < 0 ||
|
||||
state_->child.load(std::memory_order_acquire) <= 0) {
|
||||
return false;
|
||||
}
|
||||
pollfd probe = {state_->control_descriptor, POLLIN, 0};
|
||||
if (poll(&probe, 1, 0) > 0) {
|
||||
if ((probe.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0) {
|
||||
state_->child.store(-1, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
if ((probe.revents & POLLIN) != 0) {
|
||||
char peek;
|
||||
const ssize_t count = recv(state_->control_descriptor, &peek, 1,
|
||||
MSG_PEEK | MSG_DONTWAIT);
|
||||
if (count == 0) {
|
||||
state_->child.store(-1, std::memory_order_release);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
pid_t WebViewHelperProcess::process_id() const {
|
||||
return state_ != nullptr ? state_->child.load(std::memory_order_acquire) : -1;
|
||||
@@ -135,6 +170,114 @@ private:
|
||||
char *value_ = nullptr;
|
||||
};
|
||||
|
||||
bool HostIsFreeBsd() {
|
||||
char buffer[512] = {};
|
||||
std::ifstream file("/proc/version", std::ios::binary);
|
||||
file.read(buffer, sizeof(buffer) - 1);
|
||||
const std::string_view text(buffer, static_cast<std::size_t>(file.gcount()));
|
||||
return text.find("FreeBSD") != std::string_view::npos ||
|
||||
text.find("freebsd.org") != std::string_view::npos;
|
||||
}
|
||||
|
||||
std::string FramePacket(std::string_view packet) {
|
||||
std::string out(4, '\0');
|
||||
const std::size_t size = packet.size();
|
||||
out[0] = static_cast<char>((size >> 24) & 0xff);
|
||||
out[1] = static_cast<char>((size >> 16) & 0xff);
|
||||
out[2] = static_cast<char>((size >> 8) & 0xff);
|
||||
out[3] = static_cast<char>(size & 0xff);
|
||||
out.append(packet);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool ExtractFrame(std::string *buffer, std::string *frame) {
|
||||
if (buffer->size() < 4) {
|
||||
return false;
|
||||
}
|
||||
const std::size_t size =
|
||||
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[0])) << 24) |
|
||||
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[1])) << 16) |
|
||||
(static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[2])) << 8) |
|
||||
static_cast<std::size_t>(static_cast<unsigned char>((*buffer)[3]));
|
||||
if (size > kMaximumWebViewEventPacketBytes) {
|
||||
buffer->clear();
|
||||
return false;
|
||||
}
|
||||
if (buffer->size() < 4 + size) {
|
||||
return false;
|
||||
}
|
||||
frame->assign(*buffer, 4, size);
|
||||
buffer->erase(0, 4 + size);
|
||||
return true;
|
||||
}
|
||||
|
||||
int ConnectToFreeBsdHost() {
|
||||
const int descriptor = socket(AF_INET, SOCK_STREAM, 0);
|
||||
if (descriptor < 0) {
|
||||
return -1;
|
||||
}
|
||||
fcntl(descriptor, F_SETFD, FD_CLOEXEC);
|
||||
sockaddr_in address{};
|
||||
address.sin_family = AF_INET;
|
||||
address.sin_port = htons(2137);
|
||||
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
|
||||
int enabled = 1;
|
||||
setsockopt(descriptor, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled));
|
||||
if (connect(descriptor, reinterpret_cast<sockaddr *>(&address),
|
||||
sizeof(address)) != 0) {
|
||||
close(descriptor);
|
||||
return -1;
|
||||
}
|
||||
const int flags = fcntl(descriptor, F_GETFL);
|
||||
if (flags < 0 || fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != 0) {
|
||||
close(descriptor);
|
||||
return -1;
|
||||
}
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
ssize_t ReceivePacket(WebViewHelperProcess::State *state,
|
||||
std::string *packet) {
|
||||
if (!state->tcp) {
|
||||
packet->assign(kMaximumWebViewEventPacketBytes, '\0');
|
||||
const ssize_t count = recv(state->control_descriptor, packet->data(),
|
||||
packet->size(), MSG_DONTWAIT | MSG_TRUNC);
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
return -1;
|
||||
}
|
||||
if (count < 0) {
|
||||
return -2;
|
||||
}
|
||||
if (count > 0 && static_cast<std::size_t>(count) <= packet->size()) {
|
||||
packet->resize(static_cast<std::size_t>(count));
|
||||
}
|
||||
return count;
|
||||
}
|
||||
while (true) {
|
||||
if (ExtractFrame(&state->receive_buffer, packet)) {
|
||||
return static_cast<ssize_t>(packet->size());
|
||||
}
|
||||
char chunk[4096];
|
||||
const ssize_t count = read(state->control_descriptor, chunk, sizeof(chunk));
|
||||
if (count < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
return -1;
|
||||
}
|
||||
if (count < 0) {
|
||||
return -2;
|
||||
}
|
||||
if (count == 0) {
|
||||
return 0;
|
||||
}
|
||||
state->receive_buffer.append(chunk, static_cast<std::size_t>(count));
|
||||
if (state->receive_buffer.size() > kMaximumWebViewEventPacketBytes * 2) {
|
||||
return -2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool IsRobloxHost(std::string_view host) {
|
||||
constexpr std::string_view kRoot = "roblox.com";
|
||||
std::string normalized(host);
|
||||
@@ -258,13 +401,39 @@ bool SendControlCommand(
|
||||
std::lock_guard<std::mutex> lock(state->control_mutex);
|
||||
if (state->child.load(std::memory_order_acquire) > 0 &&
|
||||
state->control_descriptor >= 0) {
|
||||
ssize_t count = -1;
|
||||
do {
|
||||
// FreeBSD requires an explicit record boundary for SOCK_SEQPACKET.
|
||||
count = send(state->control_descriptor, packet.data(), packet.size(),
|
||||
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
|
||||
} while (count < 0 && errno == EINTR);
|
||||
sent = count == static_cast<ssize_t>(packet.size());
|
||||
if (state->tcp) {
|
||||
const std::string framed = FramePacket(packet);
|
||||
std::size_t offset = 0;
|
||||
sent = true;
|
||||
while (offset < framed.size()) {
|
||||
const ssize_t count =
|
||||
send(state->control_descriptor, framed.data() + offset,
|
||||
framed.size() - offset, MSG_NOSIGNAL);
|
||||
if (count < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
pollfd writable = {state->control_descriptor, POLLOUT, 0};
|
||||
if (poll(&writable, 1, 1000) <= 0) {
|
||||
sent = false;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (count <= 0) {
|
||||
sent = false;
|
||||
break;
|
||||
}
|
||||
offset += static_cast<std::size_t>(count);
|
||||
}
|
||||
} else {
|
||||
ssize_t count = -1;
|
||||
do {
|
||||
count = send(state->control_descriptor, packet.data(), packet.size(),
|
||||
MSG_DONTWAIT | MSG_NOSIGNAL | MSG_EOR);
|
||||
} while (count < 0 && errno == EINTR);
|
||||
sent = count == static_cast<ssize_t>(packet.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fill(packet.begin(), packet.end(), '\0');
|
||||
@@ -740,31 +909,41 @@ bool WebViewHelperProcess::WaitUntilReady(
|
||||
if (remaining.count() <= 0) {
|
||||
break;
|
||||
}
|
||||
std::string packet;
|
||||
bool closed = false;
|
||||
while (!state_->ready) {
|
||||
const ssize_t count = ReceivePacket(state_.get(), &packet);
|
||||
if (count == 0 || count == -2) {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if (count < 0) {
|
||||
break;
|
||||
}
|
||||
WebViewHelperEvent event;
|
||||
if (!DecodeWebViewHelperEventPacket(packet, &event)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type == WebViewHelperEventType::kReady) {
|
||||
state_->ready = true;
|
||||
} else {
|
||||
state_->pending_events.push_back(std::move(event));
|
||||
}
|
||||
}
|
||||
if (closed || state_->ready) {
|
||||
break;
|
||||
}
|
||||
pollfd descriptor = {state_->control_descriptor, POLLIN, 0};
|
||||
int poll_status = -1;
|
||||
do {
|
||||
poll_status = poll(&descriptor, 1, static_cast<int>(remaining.count()));
|
||||
} while (poll_status < 0 && errno == EINTR);
|
||||
if (poll_status <= 0 || (descriptor.revents & POLLIN) == 0) {
|
||||
if (poll_status < 0) {
|
||||
break;
|
||||
}
|
||||
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
|
||||
const ssize_t count = recv(state_->control_descriptor, packet.data(),
|
||||
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
|
||||
if (count <= 0 || static_cast<std::size_t>(count) > packet.size()) {
|
||||
if (poll_status > 0 && (descriptor.revents & (POLLERR | POLLNVAL)) != 0) {
|
||||
break;
|
||||
}
|
||||
WebViewHelperEvent event;
|
||||
if (!DecodeWebViewHelperEventPacket(
|
||||
std::string_view(packet.data(), static_cast<std::size_t>(count)),
|
||||
&event)) {
|
||||
continue;
|
||||
}
|
||||
if (event.type == WebViewHelperEventType::kReady) {
|
||||
state_->ready = true;
|
||||
} else {
|
||||
state_->pending_events.push_back(std::move(event));
|
||||
}
|
||||
}
|
||||
return state_->ready;
|
||||
}
|
||||
@@ -782,26 +961,17 @@ bool WebViewHelperProcess::DrainEvents(
|
||||
events->push_back(std::move(state_->pending_events.front()));
|
||||
state_->pending_events.pop_front();
|
||||
}
|
||||
std::string packet(kMaximumWebViewEventPacketBytes, '\0');
|
||||
std::string packet;
|
||||
while (true) {
|
||||
ssize_t count = recv(state_->control_descriptor, packet.data(),
|
||||
packet.size(), MSG_DONTWAIT | MSG_TRUNC);
|
||||
if (count < 0 && errno == EINTR) {
|
||||
continue;
|
||||
}
|
||||
if (count < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
|
||||
const ssize_t count = ReceivePacket(state_.get(), &packet);
|
||||
if (count == -1 || count == 0) {
|
||||
return true;
|
||||
}
|
||||
if (count <= 0) {
|
||||
return count == 0;
|
||||
}
|
||||
if (static_cast<std::size_t>(count) > packet.size()) {
|
||||
continue;
|
||||
if (count < 0) {
|
||||
return false;
|
||||
}
|
||||
WebViewHelperEvent event;
|
||||
if (DecodeWebViewHelperEventPacket(
|
||||
std::string_view(packet.data(), static_cast<std::size_t>(count)),
|
||||
&event)) {
|
||||
if (DecodeWebViewHelperEventPacket(packet, &event)) {
|
||||
if (event.type == WebViewHelperEventType::kReady) {
|
||||
state_->ready = true;
|
||||
} else {
|
||||
@@ -819,6 +989,9 @@ bool WebViewHelperProcess::RequestClose() const {
|
||||
if (SendControlCommand(state_, WebViewHelperControlOperation::kClose, {})) {
|
||||
return true;
|
||||
}
|
||||
if (state_->tcp) {
|
||||
return false;
|
||||
}
|
||||
const pid_t current_child = process_id();
|
||||
if (current_child <= 0) {
|
||||
return true;
|
||||
@@ -835,6 +1008,26 @@ WebViewHelperLaunchResult LaunchWebViewHelper(
|
||||
if (!NormalizeWebViewUrl(url, &normalized_url, &result.error)) {
|
||||
return result;
|
||||
}
|
||||
if (HostIsFreeBsd()) {
|
||||
const int descriptor = ConnectToFreeBsdHost();
|
||||
if (descriptor < 0) {
|
||||
result.error = "cannot connect to the FreeBSD webview host";
|
||||
return result;
|
||||
}
|
||||
auto tcp_state = std::make_shared<WebViewHelperProcess::State>();
|
||||
tcp_state->tcp = true;
|
||||
tcp_state->control_descriptor = descriptor;
|
||||
tcp_state->child.store(1, std::memory_order_release);
|
||||
result.process = std::shared_ptr<WebViewHelperProcess>(
|
||||
new WebViewHelperProcess(std::move(tcp_state)));
|
||||
result.spawned = true;
|
||||
result.process_id = 1;
|
||||
if (!result.process->LoadUrl(normalized_url)) {
|
||||
result.spawned = false;
|
||||
result.error = "cannot send the initial URL to the FreeBSD webview host";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
std::error_code filesystem_error;
|
||||
const std::filesystem::file_status helper_status =
|
||||
std::filesystem::symlink_status(helper, filesystem_error);
|
||||
|
||||
@@ -73,13 +73,24 @@ CompatibilityCatalogResult LoadCompatibilityCatalog(
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(contents, nullptr, false, true);
|
||||
if (document.is_discarded() || !document.is_object() ||
|
||||
document.value("schema_version", 0) != 1 ||
|
||||
!document.contains("profiles") || !document["profiles"].is_array()) {
|
||||
!document.contains("schema_version") ||
|
||||
!document["schema_version"].is_number_integer() ||
|
||||
document["schema_version"] != 1 || !document.contains("profiles") ||
|
||||
!document["profiles"].is_array()) {
|
||||
result.error = "compatibility manifest has an unsupported schema";
|
||||
return result;
|
||||
}
|
||||
for (const nlohmann::json& profile : document["profiles"]) {
|
||||
if (!profile.is_object() || profile.value("status", "") != "supported" ||
|
||||
if (!profile.is_object()) continue;
|
||||
if ((profile.contains("status") && !profile["status"].is_string()) ||
|
||||
(profile.contains("default_allowed") &&
|
||||
!profile["default_allowed"].is_boolean()) ||
|
||||
(profile.contains("allow_legacy_binary_patches") &&
|
||||
!profile["allow_legacy_binary_patches"].is_boolean())) {
|
||||
result.error = "compatibility profile flags have invalid types";
|
||||
return result;
|
||||
}
|
||||
if (profile.value("status", "") != "supported" ||
|
||||
!profile.value("default_allowed", false) ||
|
||||
profile.value("allow_legacy_binary_patches", true)) {
|
||||
continue;
|
||||
|
||||
+3
-1
@@ -145,8 +145,10 @@ mocktail::update::UpdatePaths ResolvePaths() {
|
||||
runtime = public_runtime;
|
||||
}
|
||||
}
|
||||
paths.runtime_binary =
|
||||
paths.runtime_launcher =
|
||||
Environment("MOCKTAIL_UPDATE_CANARY_BIN").value_or(runtime.string());
|
||||
paths.runtime_binary = Environment("MOCKTAIL_UPDATE_RUNTIME_BINARY")
|
||||
.value_or(paths.runtime_launcher.string());
|
||||
return paths;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <cctype>
|
||||
#include <cerrno>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
@@ -322,14 +323,35 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(contents, nullptr, false, true);
|
||||
if (document.is_discarded() || !document.is_object() ||
|
||||
document.value("schema_version", 0) != 1 ||
|
||||
!document.contains("version_code") ||
|
||||
!document.contains("schema_version") ||
|
||||
!document["schema_version"].is_number_integer() ||
|
||||
document["schema_version"] != 1 || !document.contains("version_code") ||
|
||||
!document["version_code"].is_number_unsigned() ||
|
||||
!document.contains("sha256") || !document["sha256"].is_object() ||
|
||||
!document.contains("assets") || !document["assets"].is_object()) {
|
||||
result.error = "payload metadata schema is invalid";
|
||||
return result;
|
||||
}
|
||||
for (const char* field : {"package", "version_name", "elf_build_id"}) {
|
||||
if (!document.contains(field) || !document[field].is_string()) {
|
||||
result.error = "payload metadata identity or hashes are invalid";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
for (const std::string& field :
|
||||
{std::string("libroblox"), std::string("base_apk"),
|
||||
std::string(compat::kGuestSplitApkHashKey)}) {
|
||||
if (!document["sha256"].contains(field) ||
|
||||
!document["sha256"][field].is_string()) {
|
||||
result.error = "payload metadata identity or hashes are invalid";
|
||||
return result;
|
||||
}
|
||||
}
|
||||
if (!document["assets"].contains("sha256_tree") ||
|
||||
!document["assets"]["sha256_tree"].is_string()) {
|
||||
result.error = "payload metadata identity or hashes are invalid";
|
||||
return result;
|
||||
}
|
||||
result.metadata.package_name = document.value("package", "");
|
||||
result.metadata.version_name = document.value("version_name", "");
|
||||
result.metadata.version_code = document["version_code"].get<std::uint64_t>();
|
||||
@@ -339,7 +361,9 @@ PayloadIntegrityResult InspectPreparedPayload(
|
||||
result.metadata.split_apk_sha256 =
|
||||
document["sha256"].value(std::string(compat::kGuestSplitApkHashKey), "");
|
||||
if (!document["assets"].contains("file_count") ||
|
||||
!document["assets"]["file_count"].is_number_unsigned()) {
|
||||
!document["assets"]["file_count"].is_number_unsigned() ||
|
||||
document["assets"]["file_count"] >
|
||||
std::numeric_limits<std::size_t>::max()) {
|
||||
result.error = "payload metadata asset count is invalid";
|
||||
return result;
|
||||
}
|
||||
|
||||
+157
-36
@@ -111,22 +111,32 @@ std::string ReadRegular(const std::filesystem::path& path, std::size_t maximum,
|
||||
|
||||
bool WriteAtomic(const std::filesystem::path& root,
|
||||
const std::filesystem::path& destination,
|
||||
std::string_view contents, std::string* error) {
|
||||
std::string_view contents, std::string* error,
|
||||
const ManifestWriteFault& fault = {}) {
|
||||
static std::uint64_t generation = 0;
|
||||
const std::filesystem::path temporary =
|
||||
root / (".manifest-" + std::to_string(getpid()) + "-" +
|
||||
std::to_string(++generation));
|
||||
const auto fail = [&](ManifestWritePhase phase) {
|
||||
if (!fault || !fault(destination, phase)) return false;
|
||||
errno = EIO;
|
||||
return true;
|
||||
};
|
||||
const int descriptor =
|
||||
open(temporary.c_str(),
|
||||
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
|
||||
fail(ManifestWritePhase::kCreate)
|
||||
? -1
|
||||
: open(temporary.c_str(),
|
||||
O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0644);
|
||||
if (descriptor < 0) {
|
||||
*error = "cannot create atomic payload manifest";
|
||||
return false;
|
||||
}
|
||||
std::size_t offset = 0;
|
||||
while (offset < contents.size()) {
|
||||
const ssize_t bytes =
|
||||
write(descriptor, contents.data() + offset, contents.size() - offset);
|
||||
const ssize_t bytes = fail(ManifestWritePhase::kWrite)
|
||||
? -1
|
||||
: write(descriptor, contents.data() + offset,
|
||||
contents.size() - offset);
|
||||
if (bytes < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
close(descriptor);
|
||||
@@ -137,7 +147,8 @@ bool WriteAtomic(const std::filesystem::path& root,
|
||||
}
|
||||
offset += static_cast<std::size_t>(bytes);
|
||||
}
|
||||
const bool synced = fsync(descriptor) == 0;
|
||||
const bool synced =
|
||||
!fail(ManifestWritePhase::kFileFsync) && fsync(descriptor) == 0;
|
||||
close(descriptor);
|
||||
if (!synced) {
|
||||
std::error_code ignored;
|
||||
@@ -146,7 +157,11 @@ bool WriteAtomic(const std::filesystem::path& root,
|
||||
return false;
|
||||
}
|
||||
std::error_code filesystem_error;
|
||||
std::filesystem::rename(temporary, destination, filesystem_error);
|
||||
if (fail(ManifestWritePhase::kRename)) {
|
||||
filesystem_error = std::make_error_code(std::errc::io_error);
|
||||
} else {
|
||||
std::filesystem::rename(temporary, destination, filesystem_error);
|
||||
}
|
||||
if (filesystem_error) {
|
||||
std::filesystem::remove(temporary, filesystem_error);
|
||||
*error = "cannot publish atomic payload manifest";
|
||||
@@ -453,14 +468,19 @@ ManifestIdentity ReadManifest(const std::filesystem::path& path,
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(result.contents, nullptr, false, true);
|
||||
if (document.is_discarded() || !document.is_object() ||
|
||||
document.value("schema_version", 0) != 1 ||
|
||||
!document.contains("schema_version") ||
|
||||
!document["schema_version"].is_number_integer() ||
|
||||
document["schema_version"] != 1 ||
|
||||
!document.contains("payload_id") || !document["payload_id"].is_string()) {
|
||||
result.error = "active payload manifest is invalid";
|
||||
return result;
|
||||
}
|
||||
result.payload_id = document["payload_id"].get<std::string>();
|
||||
if (!ValidPayloadId(result.payload_id) ||
|
||||
document.value("payload_path", "") != "payloads/" + result.payload_id) {
|
||||
!document.contains("payload_path") ||
|
||||
!document["payload_path"].is_string() ||
|
||||
document["payload_path"].get_ref<const std::string&>() !=
|
||||
"payloads/" + result.payload_id) {
|
||||
result.error = "active payload manifest identity is invalid";
|
||||
}
|
||||
return result;
|
||||
@@ -535,7 +555,14 @@ bool PopulateInspectedResult(
|
||||
if (verified_payload != nullptr) *verified_payload = payload;
|
||||
const nlohmann::json document =
|
||||
nlohmann::json::parse(manifest.contents, nullptr, false, true);
|
||||
if (document.is_discarded() ||
|
||||
if (document.is_discarded() || !document.is_object() ||
|
||||
!document.contains("version_name") ||
|
||||
!document["version_name"].is_string() ||
|
||||
!document.contains("elf_build_id") ||
|
||||
!document["elf_build_id"].is_string() ||
|
||||
!document.contains("version_code") ||
|
||||
!document["version_code"].is_number_unsigned() ||
|
||||
document["version_code"] == 0 ||
|
||||
document.value("version_name", "") != payload.metadata.version_name ||
|
||||
document.value("version_code", 0ULL) != payload.metadata.version_code ||
|
||||
document.value("elf_build_id", "") != payload.metadata.build_id) {
|
||||
@@ -550,6 +577,13 @@ bool PopulateInspectedResult(
|
||||
result->error = "active payload manifest hash does not match payload";
|
||||
return false;
|
||||
}
|
||||
for (const char* field : {"host_abi_profile_path",
|
||||
"compatibility_manifest_path", "approval_path"}) {
|
||||
if (document.contains(field) && !document[field].is_string()) {
|
||||
result->error = "active payload approval reference set is invalid";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const std::string profile = document.value("host_abi_profile_path", "");
|
||||
const std::string compatibility =
|
||||
document.value("compatibility_manifest_path", "");
|
||||
@@ -591,14 +625,120 @@ bool PersistPayloadSha256(const std::filesystem::path& root,
|
||||
error);
|
||||
}
|
||||
|
||||
constexpr const char* kPreviousRecovery = ".previous_good-recovery.json";
|
||||
constexpr const char* kAbsentRecovery = ".previous_good-recovery.absent";
|
||||
|
||||
// Inspect the directory entry itself: a dangling symlink is also a pending
|
||||
// recovery artifact, and an inspection failure must never authorize deletion.
|
||||
bool NoPendingRecovery(const std::filesystem::path& root, std::string* error) {
|
||||
for (const char* name : {kPreviousRecovery, kAbsentRecovery}) {
|
||||
std::error_code filesystem_error;
|
||||
const auto status =
|
||||
std::filesystem::symlink_status(root / name, filesystem_error);
|
||||
if (filesystem_error &&
|
||||
filesystem_error != std::errc::no_such_file_or_directory) {
|
||||
*error = "cannot inspect previous_good recovery: " +
|
||||
filesystem_error.message();
|
||||
return false;
|
||||
}
|
||||
if (status.type() != std::filesystem::file_type::not_found) {
|
||||
*error = "previous_good recovery is pending: " + (root / name).string();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RemoveRecovery(const std::filesystem::path& recovery, std::string* error,
|
||||
bool current_published = false) {
|
||||
std::error_code filesystem_error;
|
||||
std::filesystem::remove(recovery, filesystem_error);
|
||||
if (!filesystem_error) return true;
|
||||
if (!error->empty()) *error += "; ";
|
||||
if (current_published) *error += "current manifest published; ";
|
||||
*error +=
|
||||
"cannot remove previous_good recovery: " + filesystem_error.message() +
|
||||
"; recovery retained at " + recovery.string();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called only while StoreLock is held. This recovers a failed publication;
|
||||
// it does not make two manifest renames atomic against a process crash.
|
||||
bool PublishActivation(const std::filesystem::path& root,
|
||||
std::string_view payload_id, std::string_view contents,
|
||||
const ManifestWriteFault& fault, std::string* error) {
|
||||
if (!NoPendingRecovery(root, error)) return false;
|
||||
const auto current = ReadManifest(root / "current.json", true);
|
||||
if (!current) {
|
||||
*error = current.error;
|
||||
return false;
|
||||
}
|
||||
if (current.contents.empty() || current.payload_id == payload_id) {
|
||||
return WriteAtomic(root, root / "current.json", contents, error, fault);
|
||||
}
|
||||
|
||||
std::error_code filesystem_error;
|
||||
const auto previous_status = std::filesystem::symlink_status(
|
||||
root / "previous_good.json", filesystem_error);
|
||||
if (filesystem_error &&
|
||||
filesystem_error != std::errc::no_such_file_or_directory) {
|
||||
*error =
|
||||
"cannot inspect previous_good manifest: " + filesystem_error.message();
|
||||
return false;
|
||||
}
|
||||
const bool previous_present =
|
||||
previous_status.type() != std::filesystem::file_type::not_found;
|
||||
std::string previous;
|
||||
if (previous_present) {
|
||||
// Keep the original bytes, including an explicitly present empty file.
|
||||
previous = ReadRegular(root / "previous_good.json", 1024U * 1024U, error);
|
||||
if (!error->empty()) return false;
|
||||
}
|
||||
const auto recovery =
|
||||
root / (previous_present ? kPreviousRecovery : kAbsentRecovery);
|
||||
if (!WriteAtomic(root, recovery, previous_present ? previous : "absent\n",
|
||||
error, fault)) {
|
||||
return false;
|
||||
}
|
||||
if (!WriteAtomic(root, root / "previous_good.json", current.contents, error,
|
||||
fault)) {
|
||||
RemoveRecovery(recovery, error);
|
||||
return false;
|
||||
}
|
||||
if (!WriteAtomic(root, root / "current.json", contents, error, fault)) {
|
||||
std::string restore_error;
|
||||
if (previous_present) {
|
||||
WriteAtomic(root, root / "previous_good.json", previous, &restore_error,
|
||||
fault);
|
||||
} else {
|
||||
filesystem_error.clear();
|
||||
std::filesystem::remove(root / "previous_good.json", filesystem_error);
|
||||
if (filesystem_error) {
|
||||
restore_error = "cannot restore absence of previous_good: " +
|
||||
filesystem_error.message();
|
||||
}
|
||||
}
|
||||
if (!restore_error.empty()) {
|
||||
*error += "; previous_good restoration failed: " + restore_error +
|
||||
"; recovery retained at " + recovery.string();
|
||||
} else {
|
||||
RemoveRecovery(recovery, error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return RemoveRecovery(recovery, error, true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
PayloadStore::PayloadStore(std::filesystem::path root,
|
||||
std::filesystem::path compatibility_manifest,
|
||||
std::filesystem::path runtime_binary)
|
||||
std::filesystem::path runtime_binary,
|
||||
ManifestWriteFault manifest_write_fault)
|
||||
: root_(std::move(root)),
|
||||
compatibility_manifest_(std::move(compatibility_manifest)),
|
||||
runtime_binary_(std::move(runtime_binary)) {}
|
||||
runtime_binary_(std::move(runtime_binary)),
|
||||
manifest_write_fault_(std::move(manifest_write_fault)) {}
|
||||
|
||||
PayloadStoreResult PayloadStore::Stage(
|
||||
const std::filesystem::path& prepared_payload) {
|
||||
@@ -679,20 +819,9 @@ PayloadStoreResult PayloadStore::Promote(std::string_view payload_id) {
|
||||
result.version_name = payload.metadata.version_name;
|
||||
result.version_code = payload.metadata.version_code;
|
||||
result.build_id = payload.metadata.build_id;
|
||||
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
|
||||
if (!current && !current.error.empty()) {
|
||||
result.error = current.error;
|
||||
return result;
|
||||
}
|
||||
if (!current.contents.empty() && current.payload_id != result.payload_id &&
|
||||
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
|
||||
&result.error)) {
|
||||
return result;
|
||||
}
|
||||
const std::string contents = ActivationManifest(payload).dump(2) + "\n";
|
||||
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
|
||||
return result;
|
||||
}
|
||||
PublishActivation(root_, result.payload_id, contents, manifest_write_fault_,
|
||||
&result.error);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -742,17 +871,8 @@ PayloadStoreResult PayloadStore::PromoteProbation(
|
||||
&result.error)) {
|
||||
return result;
|
||||
}
|
||||
const ManifestIdentity current = ReadManifest(root_ / "current.json", true);
|
||||
if (!current && !current.error.empty()) {
|
||||
result.error = current.error;
|
||||
return result;
|
||||
}
|
||||
if (!current.contents.empty() && current.payload_id != result.payload_id &&
|
||||
!WriteAtomic(root_, root_ / "previous_good.json", current.contents,
|
||||
&result.error)) {
|
||||
return result;
|
||||
}
|
||||
if (!WriteAtomic(root_, root_ / "current.json", contents, &result.error)) {
|
||||
if (!PublishActivation(root_, result.payload_id, contents,
|
||||
manifest_write_fault_, &result.error)) {
|
||||
return result;
|
||||
}
|
||||
result.version_name = payload.metadata.version_name;
|
||||
@@ -875,6 +995,7 @@ PayloadGarbageResult PayloadStore::CollectGarbage(
|
||||
PayloadGarbageResult result;
|
||||
StoreLock lock(root_, &result.error);
|
||||
if (!lock) return result;
|
||||
if (!NoPendingRecovery(root_, &result.error)) return result;
|
||||
std::set<std::string> retained(keep.begin(), keep.end());
|
||||
for (const char* manifest : {"current.json", "previous_good.json"}) {
|
||||
const ManifestIdentity identity = ReadManifest(root_ / manifest, true);
|
||||
|
||||
@@ -302,6 +302,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"XDG_RUNTIME_DIR",
|
||||
"XDG_SESSION_TYPE",
|
||||
"SDL_VIDEO_DRIVER",
|
||||
"SDL_VIDEODRIVER",
|
||||
"MOCKTAIL_FORCE_WAYLAND",
|
||||
"MOCKTAIL_FORCE_X11",
|
||||
"MOCKTAIL_ANGLE_FORCE_X11",
|
||||
"MOCKTAIL_PREFER_WAYLAND",
|
||||
"DBUS_SESSION_BUS_ADDRESS",
|
||||
"PULSE_SERVER",
|
||||
"PIPEWIRE_REMOTE",
|
||||
@@ -419,8 +426,13 @@ CanaryResult RunReadinessCanary(const CanaryOptions& options) {
|
||||
}
|
||||
pid_t child = -1;
|
||||
if (spawn_status == 0) {
|
||||
spawn_status = posix_spawn(&child, options.runtime_binary.c_str(), &actions,
|
||||
nullptr, arguments, environment_pointers.data());
|
||||
result.spawn_error =
|
||||
options.spawn
|
||||
? options.spawn(&child, options.runtime_binary.c_str(), &actions,
|
||||
nullptr, arguments, environment_pointers.data())
|
||||
: posix_spawn(&child, options.runtime_binary.c_str(), &actions,
|
||||
nullptr, arguments, environment_pointers.data());
|
||||
spawn_status = result.spawn_error;
|
||||
}
|
||||
posix_spawn_file_actions_destroy(&actions);
|
||||
close(log_descriptor);
|
||||
|
||||
@@ -495,7 +495,9 @@ UpdateResult RunUnsafeLatest(
|
||||
// Staging preserves the immutable APK for inspection, but this path must
|
||||
// never promote it or alter the normal current payload.
|
||||
UnsafeLatestRunOptions launch;
|
||||
launch.runtime_binary = paths.runtime_binary;
|
||||
launch.runtime_binary = paths.runtime_launcher.empty()
|
||||
? paths.runtime_binary
|
||||
: paths.runtime_launcher;
|
||||
launch.payload_directory = candidate.staged.payload_directory;
|
||||
launch.compatibility_manifest = candidate.exact_supported
|
||||
? paths.compatibility_manifest
|
||||
@@ -519,9 +521,10 @@ UpdateResult RunUnsafeLatest(
|
||||
bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
|
||||
bool run_canary,
|
||||
CanaryGraphicsBackend graphics_backend,
|
||||
int progress_fd,
|
||||
int progress_fd, const CanarySpawn& spawn,
|
||||
std::array<std::filesystem::path, 2>* logs,
|
||||
std::string* error) {
|
||||
std::string* error, bool* retryable) {
|
||||
*retryable = false;
|
||||
if (!run_canary) {
|
||||
if (!candidate.exact_supported) {
|
||||
*error = "latest candidate cannot be activated without Tier C probation";
|
||||
@@ -536,7 +539,9 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
|
||||
: "Testing latest Roblox (" +
|
||||
std::to_string(index + 1) + "/2)...");
|
||||
CanaryOptions canary;
|
||||
canary.runtime_binary = paths.runtime_binary;
|
||||
canary.runtime_binary = paths.runtime_launcher.empty()
|
||||
? paths.runtime_binary
|
||||
: paths.runtime_launcher;
|
||||
canary.payload_directory = candidate.staged.payload_directory;
|
||||
canary.compatibility_manifest = candidate.exact_supported
|
||||
? paths.compatibility_manifest
|
||||
@@ -545,12 +550,14 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
|
||||
canary.cache_root = paths.cache_root;
|
||||
canary.state_root = paths.state_root;
|
||||
canary.graphics_backend = graphics_backend;
|
||||
canary.spawn = spawn;
|
||||
const CanaryResult canary_result = [&] {
|
||||
StageTimer timer("canary[" + std::to_string(index + 1) + "/" +
|
||||
std::to_string(runs) + "]");
|
||||
return RunReadinessCanary(canary);
|
||||
}();
|
||||
if (!canary_result) {
|
||||
*retryable = canary_result.spawn_error == EAGAIN;
|
||||
*error = canary_result.error;
|
||||
if (!canary_result.log_path.empty()) {
|
||||
*error += "; log: " + canary_result.log_path.string();
|
||||
@@ -565,11 +572,13 @@ bool RunCandidateCanaries(const UpdatePaths& paths, const Candidate& candidate,
|
||||
PayloadStoreResult PromoteCandidate(const UpdatePaths& paths,
|
||||
const UpdateRequest& request,
|
||||
const Candidate& candidate,
|
||||
PayloadStore* store, std::string* error) {
|
||||
PayloadStore* store, std::string* error,
|
||||
bool* retryable) {
|
||||
*retryable = false;
|
||||
std::array<std::filesystem::path, 2> logs;
|
||||
if (!RunCandidateCanaries(paths, candidate, request.run_canary,
|
||||
request.canary_graphics_backend,
|
||||
request.progress_fd, &logs, error)) {
|
||||
if (!RunCandidateCanaries(
|
||||
paths, candidate, request.run_canary, request.canary_graphics_backend,
|
||||
request.progress_fd, request.canary_spawn, &logs, error, retryable)) {
|
||||
return {};
|
||||
}
|
||||
Progress(request.progress_fd, "Installing Roblox...");
|
||||
@@ -824,14 +833,14 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
|
||||
}
|
||||
examined.push_back(candidate.staged.payload_id);
|
||||
if (request.startup_preflight && current &&
|
||||
RejectedForRuntime(paths, candidate,
|
||||
request.canary_graphics_backend)) {
|
||||
RejectedForRuntime(paths, candidate, request.canary_graphics_backend)) {
|
||||
blocked = description + " already failed probation with this runtime";
|
||||
continue;
|
||||
}
|
||||
|
||||
std::string candidate_error;
|
||||
bool candidate_rejected = false;
|
||||
bool retryable = false;
|
||||
if (!candidate.exact_supported && candidate.profile.empty()) {
|
||||
const ReferenceProfile reference =
|
||||
ResolveReference(paths, installed, catalog.profiles, &provider,
|
||||
@@ -865,9 +874,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
|
||||
}
|
||||
PayloadStoreResult promoted;
|
||||
if (candidate_error.empty()) {
|
||||
promoted =
|
||||
PromoteCandidate(paths, request, candidate, &store, &candidate_error);
|
||||
candidate_rejected = !candidate_error.empty();
|
||||
promoted = PromoteCandidate(paths, request, candidate, &store,
|
||||
&candidate_error, &retryable);
|
||||
candidate_rejected = !candidate_error.empty() && !retryable;
|
||||
}
|
||||
if (candidate_error.empty() && promoted) {
|
||||
result.changed = !current || current.payload_id != promoted.payload_id;
|
||||
|
||||
@@ -1424,6 +1424,11 @@ void Activate(GtkApplication* application, gpointer user_data) {
|
||||
state->disable_hardware_acceleration =
|
||||
mocktail::webview::ShouldDisableWebViewHardwareAcceleration(
|
||||
wayland_display, std::getenv("WEBKIT_DISABLE_COMPOSITING_MODE"));
|
||||
// GTK picks its renderer when a window is realized, which is after this.
|
||||
if (mocktail::webview::ShouldForceSoftwareGtkRenderer(
|
||||
state->disable_hardware_acceleration, std::getenv("GSK_RENDERER"))) {
|
||||
setenv("GSK_RENDERER", "cairo", 0);
|
||||
}
|
||||
std::cerr << "[webview] display=" << (wayland_display ? "wayland" : "other")
|
||||
<< " compositing="
|
||||
<< (state->disable_hardware_acceleration ? "software" : "default")
|
||||
|
||||
@@ -133,6 +133,11 @@ bool ShouldDisableWebViewHardwareAcceleration(
|
||||
return wayland_display;
|
||||
}
|
||||
|
||||
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
|
||||
const char* renderer_override) {
|
||||
return software_compositing && renderer_override == nullptr;
|
||||
}
|
||||
|
||||
const char* AndroidBridgeSource() { return kBridgeSource; }
|
||||
|
||||
std::string BuildRobloxAndroidUserAgent() {
|
||||
|
||||
@@ -38,6 +38,13 @@ bool ShouldDisableWebKitSandbox(std::string_view kernel_version,
|
||||
// Match WebKit's WEBKIT_DISABLE_COMPOSITING_MODE override semantics.
|
||||
bool ShouldDisableWebViewHardwareAcceleration(
|
||||
bool wayland_display, const char* compositing_override);
|
||||
// GTK's GL renderer aborts in libepoxy when it snapshots a page switch on
|
||||
// hosts where its GL context cannot become current. With software compositing
|
||||
// the helper does not need GL for its UI, so use the Cairo renderer unless the
|
||||
// user chose one through GSK_RENDERER.
|
||||
bool ShouldForceSoftwareGtkRenderer(bool software_compositing,
|
||||
const char* renderer_override);
|
||||
|
||||
const char* AndroidBridgeSource();
|
||||
std::string BuildRobloxAndroidUserAgent();
|
||||
bool IsBrowserLoginUrl(std::string_view url);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
#include "window/video_driver_policy.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <system_error>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
@@ -39,6 +41,16 @@ const char* VideoDriverChoiceName(VideoDriverChoice choice) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool HasNvidiaKernelDriver(const std::filesystem::path& proc_version,
|
||||
const std::filesystem::path& pci_driver) {
|
||||
if (access(proc_version.c_str(), R_OK) == 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
std::error_code error;
|
||||
return std::filesystem::is_directory(pci_driver, error);
|
||||
}
|
||||
|
||||
bool HasAvailableVideoDriverCandidate(
|
||||
std::string_view requested,
|
||||
const std::vector<std::string_view>& available_drivers) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#ifndef MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
|
||||
#define MOCKTAIL_WINDOW_VIDEO_DRIVER_POLICY_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
@@ -27,12 +28,17 @@ struct VideoDriverPolicyInput {
|
||||
|
||||
// Resolves the SDL video backend before SDL_Init. An explicit SDL driver is
|
||||
// always authoritative. NVIDIA's direct Vulkan WSI uses X11/XWayland by
|
||||
// default when both display transports are available because a blocked native
|
||||
// Wayland present cannot be cancelled without violating VkQueue ownership.
|
||||
// default when both display transports are available to avoid native Wayland
|
||||
// WSI hangs and explicit-sync protocol errors that disconnect the display.
|
||||
VideoDriverChoice ResolveVideoDriverChoice(const VideoDriverPolicyInput& input);
|
||||
|
||||
const char* VideoDriverChoiceName(VideoDriverChoice choice);
|
||||
|
||||
// Also detects sandboxed drivers through sysfs when procfs is unavailable.
|
||||
bool HasNvidiaKernelDriver(
|
||||
const std::filesystem::path& proc_version = "/proc/driver/nvidia/version",
|
||||
const std::filesystem::path& pci_driver = "/sys/bus/pci/drivers/nvidia");
|
||||
|
||||
// SDL accepts a comma-separated priority list. An inherited override is only
|
||||
// useful when at least one requested driver exists in the linked SDL build.
|
||||
bool HasAvailableVideoDriverCandidate(
|
||||
|
||||
+188
-103
@@ -13,11 +13,11 @@
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <unordered_map>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "mocktail/graphics/angle_probe.h"
|
||||
#include "mocktail/graphics/gles_text_overlay_compositor.h"
|
||||
#include "mocktail/graphics/present_mode_policy.h"
|
||||
#include "mocktail/platform/display_refresh_capabilities.h"
|
||||
@@ -66,6 +66,11 @@ static constexpr EGLint EGL_ALPHA_SIZE_VAL = 0x3021;
|
||||
static constexpr EGLint EGL_DEPTH_SIZE_VAL = 0x3025;
|
||||
static constexpr EGLint EGL_STENCIL_SIZE_VAL = 0x3026;
|
||||
static constexpr EGLint EGL_CONTEXT_CLIENT_VERSION_VAL = 0x3098;
|
||||
static constexpr EGLint EGL_EXTENSIONS_VAL = 0x3055;
|
||||
static constexpr EGLint EGL_DEBUG_MSG_CRITICAL_KHR_VAL = 0x33B9;
|
||||
static constexpr EGLint EGL_DEBUG_MSG_ERROR_KHR_VAL = 0x33BA;
|
||||
static constexpr EGLint EGL_DEBUG_MSG_WARN_KHR_VAL = 0x33BB;
|
||||
static constexpr EGLint EGL_DEBUG_MSG_INFO_KHR_VAL = 0x33BC;
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_ANGLE_VAL = 0x3202;
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_ANGLE_VAL = 0x3203;
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_ANGLE_VAL = 0x3209;
|
||||
@@ -76,6 +81,10 @@ static constexpr EGLint EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL =
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_TYPE_VULKAN_ANGLE_VAL = 0x3450;
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL =
|
||||
0x3487;
|
||||
static constexpr EGLint EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL =
|
||||
0x348F;
|
||||
static constexpr EGLint EGL_PLATFORM_X11_EXT_VAL = 0x31D5;
|
||||
static constexpr EGLint EGL_PLATFORM_WAYLAND_EXT_VAL = 0x31D8;
|
||||
static constexpr EGLint EGL_TRUE_VAL = 1;
|
||||
static constexpr EGLint EGL_FALSE_VAL = 0;
|
||||
static constexpr EGLBoolean EGL_TRUE_B = 1;
|
||||
@@ -157,8 +166,6 @@ static std::unique_ptr<SdlPointerCaptureBackend> g_pointer_capture_backend;
|
||||
static std::mutex g_pointer_capture_owner_mutex;
|
||||
static std::shared_ptr<WindowPointerCaptureOwner> g_pointer_capture_owner;
|
||||
static std::unique_ptr<graphics::GlesTextOverlayCompositor> g_gles_text_overlay;
|
||||
static char g_preferred_egl_library[4096];
|
||||
static char g_preferred_gles_library[4096];
|
||||
static bool g_auto_angle_retry_attempted = false;
|
||||
static std::filesystem::path g_window_state_path;
|
||||
|
||||
@@ -239,17 +246,8 @@ const char* GetEnvNonEmpty(const char* name) {
|
||||
if (name == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
static std::mutex mutex;
|
||||
static std::unordered_map<const char*, const char*> cache;
|
||||
std::lock_guard<std::mutex> lock(mutex);
|
||||
const auto existing = cache.find(name);
|
||||
if (existing != cache.end()) {
|
||||
return existing->second;
|
||||
}
|
||||
const char* raw = std::getenv(name);
|
||||
const char* value = (raw != nullptr && raw[0] != '\0') ? raw : nullptr;
|
||||
cache.emplace(name, value);
|
||||
return value;
|
||||
return raw != nullptr && raw[0] != '\0' ? raw : nullptr;
|
||||
}
|
||||
|
||||
bool IsEnabledEnv(const char* name) {
|
||||
@@ -435,19 +433,6 @@ bool FileExists(const char* path) {
|
||||
return path != nullptr && path[0] != '\0' && access(path, R_OK) == 0;
|
||||
}
|
||||
|
||||
bool JoinExistingLibraryPath(const char* dir, const char* soname, char* out,
|
||||
size_t out_size) {
|
||||
if (dir == nullptr || dir[0] == '\0' || soname == nullptr ||
|
||||
soname[0] == '\0' || out == nullptr || out_size == 0) {
|
||||
return false;
|
||||
}
|
||||
int written = std::snprintf(out, out_size, "%s/%s", dir, soname);
|
||||
if (written <= 0 || static_cast<size_t>(written) >= out_size) {
|
||||
return false;
|
||||
}
|
||||
return FileExists(out);
|
||||
}
|
||||
|
||||
bool ShouldUseAngleBackend() {
|
||||
const char* backend = GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND");
|
||||
return StartsWith(backend, "angle") ||
|
||||
@@ -491,8 +476,7 @@ VideoDriverChoice ResolveConfiguredVideoDriverChoice() {
|
||||
input.has_wayland_session = HasWaylandSession();
|
||||
input.has_x11_display = GetEnvNonEmpty("DISPLAY") != nullptr;
|
||||
input.uses_direct_vulkan = ShouldUseNativeVulkanBackend();
|
||||
input.has_nvidia_kernel_driver =
|
||||
access("/proc/driver/nvidia/version", R_OK) == 0;
|
||||
input.has_nvidia_kernel_driver = HasNvidiaKernelDriver();
|
||||
return ResolveVideoDriverChoice(input);
|
||||
}
|
||||
|
||||
@@ -553,25 +537,26 @@ bool ShouldShowWindowImmediately() {
|
||||
return IsEnabledEnv("MOCKTAIL_DEBUG_SHOW_WINDOW_BEFORE_FRAME");
|
||||
}
|
||||
|
||||
const char* FindAngleLibrary(const char* explicit_env, const char* soname,
|
||||
char* out, size_t out_size) {
|
||||
const char* explicit_path = GetEnvNonEmpty(explicit_env);
|
||||
if (explicit_path != nullptr) {
|
||||
return explicit_path;
|
||||
}
|
||||
struct GraphicsLibraries {
|
||||
std::string egl_path;
|
||||
std::string gles_path;
|
||||
};
|
||||
|
||||
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
|
||||
if (JoinExistingLibraryPath(angle_dir, soname, out, out_size)) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (!ShouldUseAngleBackend()) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static const char* kAngleDirs[] = {
|
||||
GraphicsLibraries FindInstalledAngleLibraries() {
|
||||
const char* runtime_directory =
|
||||
GetEnvNonEmpty("MOCKTAIL_RUNTIME_LIBRARY_DIR");
|
||||
const std::string bundled_directory =
|
||||
runtime_directory != nullptr
|
||||
? (std::filesystem::path(runtime_directory) / "angle").string()
|
||||
: std::string();
|
||||
const char* kAngleDirs[] = {
|
||||
bundled_directory.empty() ? nullptr : bundled_directory.c_str(),
|
||||
"/usr/lib64/chromium",
|
||||
"/usr/lib64/chromium-browser",
|
||||
"/usr/lib/chromium",
|
||||
"/usr/lib/chromium-browser",
|
||||
"/usr/lib64/electron43",
|
||||
"/usr/lib/electron43",
|
||||
"/usr/lib/electron42",
|
||||
"/usr/lib/electron41",
|
||||
"/usr/lib/electron40",
|
||||
@@ -582,47 +567,59 @@ const char* FindAngleLibrary(const char* explicit_env, const char* soname,
|
||||
"/opt/google/chrome-unstable",
|
||||
};
|
||||
for (const char* dir : kAngleDirs) {
|
||||
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
|
||||
return out;
|
||||
if (dir == nullptr) {
|
||||
continue;
|
||||
}
|
||||
const std::filesystem::path directory(dir);
|
||||
GraphicsLibraries libraries{(directory / "libEGL.so").string(),
|
||||
(directory / "libGLESv2.so").string()};
|
||||
if (FileExists(libraries.egl_path.c_str()) &&
|
||||
FileExists(libraries.gles_path.c_str()) &&
|
||||
graphics::InspectAngleLibraries(
|
||||
{libraries.egl_path, libraries.gles_path})
|
||||
.state == graphics::CapabilityState::kLoadable) {
|
||||
return libraries;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
return {};
|
||||
}
|
||||
|
||||
const char* FindInstalledAngleLibrary(const char* soname, char* out,
|
||||
size_t out_size) {
|
||||
static const char* kAngleDirs[] = {
|
||||
"/usr/lib/chromium",
|
||||
"/usr/lib/chromium-browser",
|
||||
"/usr/lib/electron42",
|
||||
"/usr/lib/electron41",
|
||||
"/usr/lib/electron40",
|
||||
"/usr/lib/electron39",
|
||||
"/usr/lib/cef",
|
||||
"/opt/google/chrome",
|
||||
"/opt/google/chrome-beta",
|
||||
"/opt/google/chrome-unstable",
|
||||
};
|
||||
for (const char* dir : kAngleDirs) {
|
||||
if (JoinExistingLibraryPath(dir, soname, out, out_size)) {
|
||||
return out;
|
||||
GraphicsLibraries ResolveGraphicsLibraries() {
|
||||
const char* egl_path = GetEnvNonEmpty("MOCKTAIL_EGL_LIBRARY");
|
||||
const char* gles_path = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
|
||||
GraphicsLibraries libraries{egl_path != nullptr ? egl_path : "",
|
||||
gles_path != nullptr ? gles_path : ""};
|
||||
std::filesystem::path directory;
|
||||
const char* angle_dir = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
|
||||
if (angle_dir != nullptr) {
|
||||
directory = angle_dir;
|
||||
} else if (ShouldUseAngleBackend()) {
|
||||
if (libraries.egl_path.empty() && libraries.gles_path.empty()) {
|
||||
return FindInstalledAngleLibraries();
|
||||
}
|
||||
directory =
|
||||
std::filesystem::path(libraries.egl_path.empty() ? libraries.gles_path
|
||||
: libraries.egl_path)
|
||||
.parent_path();
|
||||
} else {
|
||||
return libraries;
|
||||
}
|
||||
return nullptr;
|
||||
if (libraries.egl_path.empty()) {
|
||||
libraries.egl_path = (directory / "libEGL.so").string();
|
||||
}
|
||||
if (libraries.gles_path.empty()) {
|
||||
libraries.gles_path = (directory / "libGLESv2.so").string();
|
||||
}
|
||||
return libraries;
|
||||
}
|
||||
|
||||
bool HasInstalledAnglePair() {
|
||||
char egl_path[4096];
|
||||
char gles_path[4096];
|
||||
return FindInstalledAngleLibrary("libEGL.so", egl_path, sizeof(egl_path)) !=
|
||||
nullptr &&
|
||||
FindInstalledAngleLibrary("libGLESv2.so", gles_path,
|
||||
sizeof(gles_path)) != nullptr;
|
||||
return !FindInstalledAngleLibraries().egl_path.empty();
|
||||
}
|
||||
|
||||
SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
|
||||
SDL_EGLAttrib* attrs =
|
||||
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 7));
|
||||
static_cast<SDL_EGLAttrib*>(SDL_malloc(sizeof(SDL_EGLAttrib) * 9));
|
||||
if (attrs == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
@@ -633,6 +630,13 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
|
||||
attrs[i++] = ShouldUseAngleSwiftShader()
|
||||
? EGL_PLATFORM_ANGLE_DEVICE_TYPE_SWIFTSHADER_ANGLE_VAL
|
||||
: EGL_PLATFORM_ANGLE_DEVICE_TYPE_HARDWARE_ANGLE_VAL;
|
||||
const char* video_driver = SDL_GetCurrentVideoDriver();
|
||||
if (StringEquals(video_driver, "x11") || StringEquals(video_driver, "wayland")) {
|
||||
attrs[i++] = EGL_PLATFORM_ANGLE_NATIVE_PLATFORM_TYPE_ANGLE_VAL;
|
||||
attrs[i++] = StringEquals(video_driver, "x11")
|
||||
? EGL_PLATFORM_X11_EXT_VAL
|
||||
: EGL_PLATFORM_WAYLAND_EXT_VAL;
|
||||
}
|
||||
if (IsEnabledEnv("MOCKTAIL_ANGLE_DEBUG_LAYERS")) {
|
||||
attrs[i++] = EGL_PLATFORM_ANGLE_DEBUG_LAYERS_ENABLED_ANGLE_VAL;
|
||||
attrs[i++] = EGL_TRUE_VAL;
|
||||
@@ -641,10 +645,7 @@ SDL_EGLAttrib* SDLCALL AnglePlatformAttributes(void* /*userdata*/) {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
void ConfigureGraphicsBackendBeforeSDL() {
|
||||
g_preferred_egl_library[0] = '\0';
|
||||
g_preferred_gles_library[0] = '\0';
|
||||
|
||||
bool ConfigureGraphicsBackendBeforeSDL() {
|
||||
SanitizeSdlVideoDriverOverrides();
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
|
||||
@@ -670,21 +671,47 @@ void ConfigureGraphicsBackendBeforeSDL() {
|
||||
"X11/XWayland WSI; set SDL_VIDEODRIVER=wayland to override\n");
|
||||
}
|
||||
|
||||
const char* egl_library = FindAngleLibrary(
|
||||
"MOCKTAIL_EGL_LIBRARY", "libEGL.so", g_preferred_egl_library,
|
||||
sizeof(g_preferred_egl_library));
|
||||
const char* gles_library = FindAngleLibrary(
|
||||
"MOCKTAIL_GLES_LIBRARY", "libGLESv2.so", g_preferred_gles_library,
|
||||
sizeof(g_preferred_gles_library));
|
||||
const GraphicsLibraries libraries = ResolveGraphicsLibraries();
|
||||
const char* egl_library =
|
||||
libraries.egl_path.empty() ? nullptr : libraries.egl_path.c_str();
|
||||
const char* gles_library =
|
||||
libraries.gles_path.empty() ? nullptr : libraries.gles_path.c_str();
|
||||
|
||||
if (ShouldUseAngleBackend() && !ShouldUseNativeVulkanBackend() &&
|
||||
(!FileExists(egl_library) || !FileExists(gles_library))) {
|
||||
const char* directory = GetEnvNonEmpty("MOCKTAIL_ANGLE_LIB_DIR");
|
||||
SDL_SetError(
|
||||
"ANGLE requires compatible libEGL.so and libGLESv2.so; "
|
||||
"directory=%s egl=%s gles=%s",
|
||||
directory != nullptr ? directory : "(automatic)",
|
||||
egl_library != nullptr ? egl_library : "(missing)",
|
||||
gles_library != nullptr ? gles_library : "(missing)");
|
||||
fprintf(stderr, " [window] %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ShouldUseAngleVulkanBackend()) {
|
||||
const auto capability =
|
||||
graphics::InspectAngleLibraries({egl_library, gles_library});
|
||||
if (capability.state != graphics::CapabilityState::kLoadable) {
|
||||
SDL_SetError("ANGLE unavailable: %s", capability.detail.c_str());
|
||||
fprintf(stderr, " [window] %s\n", SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (egl_library != nullptr) {
|
||||
setenv("MOCKTAIL_EGL_LIBRARY", egl_library, 0);
|
||||
SDL_SetHint(SDL_HINT_EGL_LIBRARY, egl_library);
|
||||
SDL_SetHintWithPriority(SDL_HINT_EGL_LIBRARY, egl_library,
|
||||
ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
|
||||
: SDL_HINT_NORMAL);
|
||||
}
|
||||
if (gles_library != nullptr) {
|
||||
setenv("MOCKTAIL_GLES_LIBRARY", gles_library, 0);
|
||||
SDL_SetHint(SDL_HINT_OPENGL_LIBRARY, gles_library);
|
||||
SDL_SetHint(SDL_HINT_OPENGL_ES_DRIVER, "1");
|
||||
const auto priority = ShouldUseAngleBackend() ? SDL_HINT_OVERRIDE
|
||||
: SDL_HINT_NORMAL;
|
||||
SDL_SetHintWithPriority(SDL_HINT_OPENGL_LIBRARY, gles_library, priority);
|
||||
SDL_SetHintWithPriority(SDL_HINT_OPENGL_ES_DRIVER, "1", priority);
|
||||
}
|
||||
|
||||
if (ShouldUseAngleVulkanBackend()) {
|
||||
@@ -693,37 +720,88 @@ void ConfigureGraphicsBackendBeforeSDL() {
|
||||
|
||||
if (ShouldUseAngleBackend() || egl_library != nullptr ||
|
||||
gles_library != nullptr || WindowTraceEnabled()) {
|
||||
const char* requested_video_driver = GetEnvNonEmpty("SDL_VIDEO_DRIVER");
|
||||
if (requested_video_driver == nullptr) {
|
||||
requested_video_driver = GetEnvNonEmpty("SDL_VIDEODRIVER");
|
||||
}
|
||||
fprintf(stderr, " [window] graphics backend=%s video=%s egl=%s gles=%s\n",
|
||||
GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
|
||||
? GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND")
|
||||
: "system",
|
||||
GetEnvNonEmpty("SDL_VIDEODRIVER")
|
||||
? GetEnvNonEmpty("SDL_VIDEODRIVER")
|
||||
: "(SDL default)",
|
||||
requested_video_driver ? requested_video_driver : "(SDL default)",
|
||||
egl_library ? egl_library : "(system)",
|
||||
gles_library ? gles_library : "(system)");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ConfigureGraphicsBackendAfterSDLInit() {
|
||||
void LogEglError(EGLenum error, const char* command, EGLint /*message_type*/,
|
||||
void* /*thread_label*/, void* /*object_label*/,
|
||||
const char* message) {
|
||||
fprintf(stderr, " [window][EGL] %s: error=0x%04x %s\n",
|
||||
command != nullptr ? command : "(unknown call)", error,
|
||||
message != nullptr ? message : "(no details)");
|
||||
}
|
||||
|
||||
void EnableEglErrorLogging() {
|
||||
using QueryString = const char* (*)(EGLDisplay, EGLint);
|
||||
using DebugCallback = void (*)(EGLenum, const char*, EGLint, void*, void*,
|
||||
const char*);
|
||||
using DebugMessageControl = EGLint (*)(DebugCallback, const SDL_EGLAttrib*);
|
||||
const auto query_string = reinterpret_cast<QueryString>(
|
||||
SDL_EGL_GetProcAddress("eglQueryString"));
|
||||
if (query_string == nullptr) {
|
||||
return;
|
||||
}
|
||||
const char* extensions = query_string(EGL_NO_DISPLAY, EGL_EXTENSIONS_VAL);
|
||||
const std::string extension_list =
|
||||
std::string(" ") + (extensions != nullptr ? extensions : "") + " ";
|
||||
if (extension_list.find(" EGL_KHR_debug ") == std::string::npos) {
|
||||
return;
|
||||
}
|
||||
const auto debug_message_control = reinterpret_cast<DebugMessageControl>(
|
||||
SDL_EGL_GetProcAddress("eglDebugMessageControlKHR"));
|
||||
if (debug_message_control != nullptr) {
|
||||
const SDL_EGLAttrib attributes[] = {
|
||||
EGL_DEBUG_MSG_CRITICAL_KHR_VAL, EGL_TRUE_VAL,
|
||||
EGL_DEBUG_MSG_ERROR_KHR_VAL, EGL_TRUE_VAL,
|
||||
EGL_DEBUG_MSG_WARN_KHR_VAL, EGL_FALSE_VAL,
|
||||
EGL_DEBUG_MSG_INFO_KHR_VAL, EGL_FALSE_VAL, EGL_NONE_VAL};
|
||||
debug_message_control(LogEglError, attributes);
|
||||
}
|
||||
}
|
||||
|
||||
bool ConfigureGraphicsBackendAfterSDLInit() {
|
||||
if (ShouldUseAngleVulkanBackend()) {
|
||||
if (!SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL)) {
|
||||
fprintf(stderr, " [window] cannot select ANGLE EGL platform: %s\n",
|
||||
SDL_GetError());
|
||||
return false;
|
||||
}
|
||||
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
|
||||
nullptr);
|
||||
fprintf(stderr,
|
||||
" [window] ANGLE Vulkan EGL platform attributes enabled "
|
||||
"(video=%s)\n", SDL_GetCurrentVideoDriver());
|
||||
}
|
||||
|
||||
const char* gles_library = GetEnvNonEmpty("MOCKTAIL_GLES_LIBRARY");
|
||||
if (gles_library != nullptr) {
|
||||
if (!SDL_GL_LoadLibrary(gles_library)) {
|
||||
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) failed: %s\n",
|
||||
gles_library, SDL_GetError());
|
||||
} else if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
|
||||
return false;
|
||||
}
|
||||
if (WindowTraceEnabled() || ShouldUseAngleBackend()) {
|
||||
fprintf(stderr, " [window] SDL_GL_LoadLibrary(%s) succeeded\n",
|
||||
gles_library);
|
||||
}
|
||||
if (WindowTraceEnabled() || TraceFlagEnabled("MOCKTAIL_EGL_TRACE")) {
|
||||
EnableEglErrorLogging();
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldUseAngleVulkanBackend()) {
|
||||
SDL_GL_SetAttribute(SDL_GL_EGL_PLATFORM, EGL_PLATFORM_ANGLE_ANGLE_VAL);
|
||||
SDL_EGL_SetAttributeCallbacks(AnglePlatformAttributes, nullptr, nullptr,
|
||||
nullptr);
|
||||
fprintf(stderr,
|
||||
" [window] ANGLE Vulkan EGL platform attributes enabled\n");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ShowWindowAccordingToStartupMode() {
|
||||
@@ -1038,7 +1116,9 @@ bool Init(int width, int height, const char* title) {
|
||||
g_fullscreen_menu_request_gate.Reset();
|
||||
g_real_swap_count.store(0, std::memory_order_relaxed);
|
||||
|
||||
ConfigureGraphicsBackendBeforeSDL();
|
||||
if (!ConfigureGraphicsBackendBeforeSDL()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (StringEquals(GetEnvNonEmpty("MOCKTAIL_GRAPHICS_BACKEND"),
|
||||
"direct-vulkan")) {
|
||||
@@ -1178,11 +1258,10 @@ bool Init(int width, int height, const char* title) {
|
||||
return false;
|
||||
}
|
||||
if (WindowTraceEnabled()) {
|
||||
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded\n");
|
||||
fprintf(stderr, " [window] SDL_Init(SDL_INIT_VIDEO) succeeded (video=%s)\n",
|
||||
SDL_GetCurrentVideoDriver());
|
||||
}
|
||||
|
||||
ConfigureGraphicsBackendAfterSDLInit();
|
||||
|
||||
// SDL may recheck this hint during context creation.
|
||||
SDL_SetHint(SDL_HINT_VIDEO_FORCE_EGL, "1");
|
||||
|
||||
@@ -1197,6 +1276,11 @@ bool Init(int width, int height, const char* title) {
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
|
||||
if (!ConfigureGraphicsBackendAfterSDLInit()) {
|
||||
SDL_Quit();
|
||||
return false;
|
||||
}
|
||||
|
||||
const WindowStartupPresentationPlan presentation =
|
||||
RestoredWindowPresentationPlan();
|
||||
SDL_WindowFlags window_flags = ApplyHighPixelDensityWindowFlag(
|
||||
@@ -2273,7 +2357,8 @@ bool PumpEvents() {
|
||||
std::get_if<platform::MouseMotionEvent>(&pending_motion.payload);
|
||||
const auto* nxt =
|
||||
std::get_if<platform::MouseMotionEvent>(&platform_event.payload);
|
||||
if (cur != nullptr && nxt != nullptr) {
|
||||
if (cur != nullptr && nxt != nullptr &&
|
||||
cur->relative == nxt->relative) {
|
||||
cur->delta_x += nxt->delta_x;
|
||||
cur->delta_y += nxt->delta_y;
|
||||
cur->x = nxt->x;
|
||||
|
||||
@@ -39,10 +39,14 @@ bool WindowPointerCaptureOwner::RegisterQuery(MouseLockQueryCallback callback,
|
||||
|
||||
void WindowPointerCaptureOwner::ClearQuery() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
if (clearing_ && t_pointer_query_owner == this) {
|
||||
return;
|
||||
}
|
||||
condition_.wait(lock, [this] { return !clearing_; });
|
||||
clearing_ = true;
|
||||
callback_ = nullptr;
|
||||
context_ = nullptr;
|
||||
condition_.notify_all();
|
||||
condition_.wait(lock, [this] {
|
||||
return in_flight_ == 0 || t_pointer_query_owner == this;
|
||||
});
|
||||
|
||||
@@ -56,6 +56,8 @@ class WindowPointerCaptureOwner final {
|
||||
bool cursor_visible() const { return cursor_visible_; }
|
||||
|
||||
private:
|
||||
friend class WindowPointerCaptureOwnerTestPeer;
|
||||
|
||||
bool Apply(bool capture, bool cursor_visible);
|
||||
|
||||
PointerCaptureBackend* backend_ = nullptr;
|
||||
|
||||
@@ -42,6 +42,7 @@ struct AAsset {
|
||||
std::string path;
|
||||
std::string apk_entry;
|
||||
std::vector<unsigned char> data;
|
||||
bool transcoded = false;
|
||||
size_t offset = 0;
|
||||
};
|
||||
struct AAssetDir {};
|
||||
@@ -341,7 +342,8 @@ AAsset* AAssetManager_open(AAssetManager* mgr, const char* filename,
|
||||
return nullptr;
|
||||
}
|
||||
if (MocktailUsesDirectVulkan()) {
|
||||
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
|
||||
asset->transcoded =
|
||||
libc_shim::TranscodeEtc1SkyTextureForVulkan(path.c_str(), &asset->data);
|
||||
}
|
||||
if (AssetTraceEnabled()) {
|
||||
std::fprintf(stderr, "[asset] open %s -> %s (%zu bytes)\n", filename,
|
||||
@@ -455,6 +457,10 @@ int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
|
||||
if (asset == nullptr || asset->path.empty()) {
|
||||
return -1;
|
||||
}
|
||||
// Force buffered reads: files still contain the original ETC1 bytes.
|
||||
if (asset->transcoded) {
|
||||
return -1;
|
||||
}
|
||||
int fd = -1;
|
||||
off_t start = 0;
|
||||
off_t descriptor_length = static_cast<off_t>(asset->data.size());
|
||||
|
||||
@@ -11,6 +11,13 @@ target_link_libraries(jnivm_test PRIVATE
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
add_executable(jnivm_fields_test jnivm_fields_test.cc)
|
||||
target_link_libraries(jnivm_fields_test PRIVATE
|
||||
Mocktail::LegacyJni
|
||||
GTest::gtest_main
|
||||
Threads::Threads
|
||||
)
|
||||
|
||||
add_executable(runtime_config_test runtime_config_test.cc)
|
||||
target_link_libraries(runtime_config_test PRIVATE
|
||||
Mocktail::Runtime
|
||||
@@ -22,6 +29,13 @@ target_link_libraries(fleasion_probe PRIVATE Mocktail::Services libc_shim)
|
||||
mocktail_apply_compile_options(fleasion_probe)
|
||||
find_program(MOCKTAIL_TEST_PYTHON python3)
|
||||
find_program(MOCKTAIL_TEST_OPENSSL openssl)
|
||||
if(MOCKTAIL_TEST_PYTHON)
|
||||
add_test(NAME PayloadImportRollbackTest.PreservesPreviousInstallation
|
||||
COMMAND ${MOCKTAIL_TEST_PYTHON}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/payload_import_rollback_test.py)
|
||||
set_tests_properties(PayloadImportRollbackTest.PreservesPreviousInstallation
|
||||
PROPERTIES TIMEOUT 180)
|
||||
endif()
|
||||
if(MOCKTAIL_TEST_PYTHON AND MOCKTAIL_TEST_OPENSSL)
|
||||
add_test(NAME FleasionIntegrationTest.ProxyAndTrust
|
||||
COMMAND ${MOCKTAIL_TEST_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/fleasion_integration_test.py
|
||||
@@ -72,6 +86,12 @@ target_compile_definitions(native_update_test PRIVATE
|
||||
MOCKTAIL_TEST_SOURCE_DIR="${PROJECT_SOURCE_DIR}"
|
||||
)
|
||||
|
||||
add_executable(compatibility_catalog_test compatibility_catalog_test.cc)
|
||||
target_link_libraries(compatibility_catalog_test PRIVATE
|
||||
Mocktail::Update
|
||||
GTest::gtest_main
|
||||
)
|
||||
|
||||
add_executable(apk_provider_test apk_provider_test.cc)
|
||||
target_link_libraries(apk_provider_test PRIVATE
|
||||
Mocktail::Update
|
||||
@@ -978,11 +998,13 @@ target_link_libraries(headless_graphics_test PRIVATE
|
||||
|
||||
include(GoogleTest)
|
||||
gtest_discover_tests(jnivm_test)
|
||||
gtest_discover_tests(jnivm_fields_test)
|
||||
gtest_discover_tests(runtime_config_test)
|
||||
gtest_discover_tests(runtime_config_file_test)
|
||||
gtest_discover_tests(payload_update_preflight_test)
|
||||
gtest_discover_tests(apk_provider_test)
|
||||
gtest_discover_tests(native_update_test)
|
||||
gtest_discover_tests(compatibility_catalog_test)
|
||||
gtest_discover_tests(support_bundle_runtime_test)
|
||||
gtest_discover_tests(vulkan_icd_selection_test)
|
||||
gtest_discover_tests(session_log_test)
|
||||
@@ -1135,6 +1157,11 @@ add_test(
|
||||
)
|
||||
|
||||
find_program(MOCKTAIL_PATCHELF_EXECUTABLE patchelf)
|
||||
add_test(
|
||||
NAME StandaloneWebkitCacheTest.RelocatesAllLoaderNames
|
||||
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/standalone_webkit_cache_test.sh
|
||||
${CMAKE_SOURCE_DIR}
|
||||
)
|
||||
if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
|
||||
add_test(
|
||||
NAME PortablePackagingTest.RelocatableReadOnlyBundle
|
||||
@@ -1142,6 +1169,9 @@ if(MOCKTAIL_BUILD_FREEBSD_SOCKET_HELPER AND MOCKTAIL_PATCHELF_EXECUTABLE)
|
||||
MOCKTAIL_PORTABLE_TEST_BUILD_DIR=${CMAKE_BINARY_DIR}
|
||||
bash ${CMAKE_CURRENT_SOURCE_DIR}/portable_packaging_test.sh
|
||||
)
|
||||
# Copies and verifies a full JVM/WebKit bundle, unlike the unit tests.
|
||||
set_tests_properties(PortablePackagingTest.RelocatableReadOnlyBundle
|
||||
PROPERTIES TIMEOUT 600)
|
||||
endif()
|
||||
|
||||
add_test(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
@@ -26,20 +27,47 @@ off_t AAsset_getRemainingLength(AAsset* asset);
|
||||
off_t AAsset_seek(AAsset* asset, off_t offset, int whence);
|
||||
int AAsset_openFileDescriptor(AAsset* asset, off_t* outStart,
|
||||
off_t* outLength);
|
||||
int AAsset_openFileDescriptor64(AAsset* asset, off_t* outStart,
|
||||
off_t* outLength);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<unsigned char> MakeEtc1SkyTexture() {
|
||||
std::vector<unsigned char> data = {
|
||||
0xab, 0x4b, 0x54, 0x58, 0x20, 0x31, 0x31, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
};
|
||||
// KTX1: one 4x4 ETC1 mip and its byte count.
|
||||
for (uint32_t value : {0x04030201U, 0U, 1U, 0U, 0x8d64U, 0x1907U,
|
||||
4U, 4U, 0U, 0U, 1U, 1U, 0U, 8U}) {
|
||||
for (unsigned shift = 0; shift < 32; shift += 8) {
|
||||
data.push_back(static_cast<unsigned char>(value >> shift));
|
||||
}
|
||||
}
|
||||
const std::array<unsigned char, 8> block = {0x55, 0x77, 0x99, 0, 0, 0, 0, 0};
|
||||
data.insert(data.end(), block.begin(), block.end());
|
||||
return data;
|
||||
}
|
||||
|
||||
class AssetManagerTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
root_ = MakeTempRoot();
|
||||
ASSERT_FALSE(root_.empty());
|
||||
setenv("MOCKTAIL_ASSET_ROOT", root_.c_str(), 1);
|
||||
const char* backend = std::getenv("MOCKTAIL_GRAPHICS_BACKEND");
|
||||
had_backend_ = backend != nullptr;
|
||||
previous_backend_ = backend != nullptr ? backend : "";
|
||||
unsetenv("MOCKTAIL_GRAPHICS_BACKEND");
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
unsetenv("MOCKTAIL_ASSET_ROOT");
|
||||
if (had_backend_) {
|
||||
setenv("MOCKTAIL_GRAPHICS_BACKEND", previous_backend_.c_str(), 1);
|
||||
} else {
|
||||
unsetenv("MOCKTAIL_GRAPHICS_BACKEND");
|
||||
}
|
||||
for (const std::string& root : roots_to_remove_) {
|
||||
std::filesystem::remove_all(root);
|
||||
}
|
||||
@@ -71,8 +99,20 @@ class AssetManagerTest : public ::testing::Test {
|
||||
ASSERT_EQ(std::fclose(file), 0);
|
||||
}
|
||||
|
||||
void WriteBinaryAsset(const std::string& name,
|
||||
const std::vector<unsigned char>& data) {
|
||||
const std::filesystem::path path = std::filesystem::path(root_) / name;
|
||||
std::filesystem::create_directories(path.parent_path());
|
||||
std::ofstream file(path, std::ios::binary);
|
||||
file.write(reinterpret_cast<const char*>(data.data()), data.size());
|
||||
file.close();
|
||||
ASSERT_TRUE(file);
|
||||
}
|
||||
|
||||
std::string root_;
|
||||
std::vector<std::string> roots_to_remove_;
|
||||
bool had_backend_ = false;
|
||||
std::string previous_backend_;
|
||||
};
|
||||
|
||||
TEST_F(AssetManagerTest, FromJavaReturnsSingletonManager) {
|
||||
@@ -155,6 +195,65 @@ TEST_F(AssetManagerTest, OpenFileDescriptorReturnsReadableFd) {
|
||||
AAsset_close(asset);
|
||||
}
|
||||
|
||||
TEST_F(AssetManagerTest, TranscodedSkyCannotBypassConversionThroughDescriptor) {
|
||||
const auto original = MakeEtc1SkyTexture();
|
||||
const std::string name = "assets/android/textures/sky/test.tex";
|
||||
WriteBinaryAsset(name, original);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "direct-vulkan", 1), 0);
|
||||
|
||||
AAsset* asset = AAssetManager_open(
|
||||
AAssetManager_fromJava(nullptr, nullptr), name.c_str(), 0);
|
||||
ASSERT_NE(asset, nullptr);
|
||||
ASSERT_EQ(AAsset_getLength(asset), static_cast<off_t>(original.size()));
|
||||
const auto* buffer = static_cast<const unsigned char*>(AAsset_getBuffer(asset));
|
||||
ASSERT_NE(buffer, nullptr);
|
||||
// BC1 format in the KTX header.
|
||||
EXPECT_EQ(buffer[28], 0xf0);
|
||||
EXPECT_EQ(buffer[29], 0x83);
|
||||
|
||||
for (auto open_descriptor : {AAsset_openFileDescriptor,
|
||||
AAsset_openFileDescriptor64}) {
|
||||
off_t start = -1;
|
||||
off_t length = -1;
|
||||
const int fd = open_descriptor(asset, &start, &length);
|
||||
EXPECT_LT(fd, 0);
|
||||
if (fd >= 0) close(fd);
|
||||
}
|
||||
|
||||
std::vector<unsigned char> readback(original.size());
|
||||
EXPECT_EQ(AAsset_read(asset, readback.data(), readback.size()),
|
||||
static_cast<int>(readback.size()));
|
||||
EXPECT_EQ(readback, std::vector<unsigned char>(buffer, buffer + readback.size()));
|
||||
AAsset_close(asset);
|
||||
|
||||
std::vector<unsigned char> on_disk(original.size());
|
||||
std::ifstream file(root_ + "/" + name, std::ios::binary);
|
||||
file.read(reinterpret_cast<char*>(on_disk.data()), on_disk.size());
|
||||
EXPECT_EQ(on_disk, original);
|
||||
}
|
||||
|
||||
TEST_F(AssetManagerTest, OpenGlSkyKeepsOriginalFileDescriptor) {
|
||||
const auto original = MakeEtc1SkyTexture();
|
||||
const std::string name = "assets/android/textures/sky/test.tex";
|
||||
WriteBinaryAsset(name, original);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "opengl", 1), 0);
|
||||
|
||||
AAsset* asset = AAssetManager_open(
|
||||
AAssetManager_fromJava(nullptr, nullptr), name.c_str(), 0);
|
||||
ASSERT_NE(asset, nullptr);
|
||||
off_t start = -1;
|
||||
off_t length = -1;
|
||||
const int fd = AAsset_openFileDescriptor64(asset, &start, &length);
|
||||
ASSERT_GE(fd, 0);
|
||||
EXPECT_EQ(length, static_cast<off_t>(original.size()));
|
||||
std::vector<unsigned char> readback(original.size());
|
||||
EXPECT_EQ(pread(fd, readback.data(), readback.size(), start),
|
||||
static_cast<ssize_t>(readback.size()));
|
||||
EXPECT_EQ(readback, original);
|
||||
close(fd);
|
||||
AAsset_close(asset);
|
||||
}
|
||||
|
||||
TEST_F(AssetManagerTest, MissingOrUnsafeAssetsReturnNull) {
|
||||
AAssetManager* manager = AAssetManager_fromJava(nullptr, nullptr);
|
||||
EXPECT_EQ(AAssetManager_open(manager, "missing.bin", 0), nullptr);
|
||||
|
||||
@@ -30,19 +30,60 @@ namespace {
|
||||
|
||||
using namespace std::chrono_literals;
|
||||
|
||||
class ReleaseGate {
|
||||
public:
|
||||
void WaitBeforeRelease() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
waiting_ = true;
|
||||
cv_.notify_all();
|
||||
cv_.wait(lock, [this] { return open_; });
|
||||
}
|
||||
|
||||
bool WaitUntilBlocked() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
return cv_.wait_for(lock, 2s, [this] { return waiting_; });
|
||||
}
|
||||
|
||||
void Open() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
open_ = true;
|
||||
}
|
||||
cv_.notify_all();
|
||||
}
|
||||
|
||||
private:
|
||||
std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
bool waiting_ = false;
|
||||
bool open_ = false;
|
||||
};
|
||||
|
||||
class ControlledAudioSink final : public AudioSink {
|
||||
public:
|
||||
explicit ControlledAudioSink(bool release_on_enqueue = false)
|
||||
: release_on_enqueue_(release_on_enqueue) {}
|
||||
|
||||
const PcmSpec& source_spec() const override { return spec_; }
|
||||
|
||||
Status Enqueue(const PcmBuffer& buffer) override {
|
||||
if (buffer.data == nullptr || buffer.size_bytes == 0) {
|
||||
return Status::Error(StatusCode::kInvalidArgument, "empty buffer");
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (stopped_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition, "stopped");
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (stopped_) {
|
||||
return Status::Error(StatusCode::kFailedPrecondition, "stopped");
|
||||
}
|
||||
if (reject_next_enqueue_) {
|
||||
reject_next_enqueue_ = false;
|
||||
return Status::Error(StatusCode::kUnavailable, "enqueue rejected");
|
||||
}
|
||||
buffers_.push_back(buffer);
|
||||
}
|
||||
if (release_on_enqueue_) {
|
||||
ReleaseOne();
|
||||
}
|
||||
buffers_.push_back(buffer);
|
||||
return Status::Ok();
|
||||
}
|
||||
|
||||
@@ -93,10 +134,13 @@ class ControlledAudioSink final : public AudioSink {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
stopped_ = true;
|
||||
}
|
||||
release_cv_.notify_all();
|
||||
ReleaseAll();
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
release_cv_.wait(lock, [this] { return active_releases_ == 0; });
|
||||
}
|
||||
|
||||
void ReleaseOne() {
|
||||
void ReleaseOne(ReleaseGate* gate = nullptr) {
|
||||
PcmBuffer buffer;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -105,11 +149,30 @@ class ControlledAudioSink final : public AudioSink {
|
||||
}
|
||||
buffer = buffers_.front();
|
||||
buffers_.erase(buffers_.begin());
|
||||
++active_releases_;
|
||||
}
|
||||
if (gate != nullptr) {
|
||||
gate->WaitBeforeRelease();
|
||||
}
|
||||
if (buffer.release_callback != nullptr) {
|
||||
buffer.release_callback(buffer.release_context, buffer.data,
|
||||
buffer.size_bytes);
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
--active_releases_;
|
||||
}
|
||||
release_cv_.notify_all();
|
||||
}
|
||||
|
||||
bool WaitUntilShutdownStarts() {
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
return release_cv_.wait_for(lock, 2s, [this] { return stopped_; });
|
||||
}
|
||||
|
||||
void RejectNextEnqueue() {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
reject_next_enqueue_ = true;
|
||||
}
|
||||
|
||||
std::size_t buffer_count() const {
|
||||
@@ -141,11 +204,36 @@ class ControlledAudioSink final : public AudioSink {
|
||||
}
|
||||
|
||||
const PcmSpec spec_;
|
||||
const bool release_on_enqueue_;
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable release_cv_;
|
||||
std::vector<PcmBuffer> buffers_;
|
||||
std::size_t active_releases_ = 0;
|
||||
bool playing_ = false;
|
||||
float gain_ = 1.0F;
|
||||
bool stopped_ = false;
|
||||
bool reject_next_enqueue_ = false;
|
||||
};
|
||||
|
||||
class DelayedBufferRelease {
|
||||
public:
|
||||
explicit DelayedBufferRelease(ControlledAudioSink* sink)
|
||||
: thread_([this, sink] { sink->ReleaseOne(&gate_); }) {}
|
||||
|
||||
~DelayedBufferRelease() {
|
||||
if (thread_.joinable()) {
|
||||
gate_.Open();
|
||||
thread_.join();
|
||||
}
|
||||
}
|
||||
|
||||
bool WaitUntilBlocked() { return gate_.WaitUntilBlocked(); }
|
||||
void AllowRelease() { gate_.Open(); }
|
||||
void Join() { thread_.join(); }
|
||||
|
||||
private:
|
||||
ReleaseGate gate_;
|
||||
std::thread thread_;
|
||||
};
|
||||
|
||||
struct CallbackProbe {
|
||||
@@ -389,6 +477,7 @@ TEST(SdlAudioSinkTest, ReleasesBorrowedBufferWhenStreamIsCleared) {
|
||||
TEST(OpenSlQueueTest, DeliversPcmToSinkAndDispatchesCompletion) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
CallbackProbe probe;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
Status status = OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
{}, &adapter);
|
||||
@@ -396,7 +485,6 @@ TEST(OpenSlQueueTest, DeliversPcmToSinkAndDispatchesCompletion) {
|
||||
|
||||
auto queue = adapter->interface();
|
||||
ASSERT_NE(queue, nullptr);
|
||||
CallbackProbe probe;
|
||||
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
|
||||
opensl_abi::kResultSuccess);
|
||||
|
||||
@@ -520,6 +608,204 @@ TEST(OpenSlQueueTest, ClearSuppressesCallbacksForDiscardedBuffers) {
|
||||
EXPECT_EQ(probe.calls, 0);
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, DelayedReleaseAfterClearPreservesReenqueuedBuffer) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
options.max_buffers = 1;
|
||||
CallbackProbe probe;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
options, &adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
|
||||
opensl_abi::kResultSuccess);
|
||||
const std::vector<std::int16_t> samples(32 * 2, 789);
|
||||
const auto bytes =
|
||||
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
|
||||
DelayedBufferRelease release(controlled_view);
|
||||
ASSERT_TRUE(release.WaitUntilBlocked());
|
||||
ASSERT_EQ(controlled_view->buffer_count(), 0u);
|
||||
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
release.AllowRelease();
|
||||
release.Join();
|
||||
|
||||
opensl_abi::AndroidSimpleBufferQueueState queue_state{};
|
||||
ASSERT_EQ((*queue)->GetState(queue, &queue_state),
|
||||
opensl_abi::kResultSuccess);
|
||||
EXPECT_EQ(queue_state.count, 1u);
|
||||
EXPECT_EQ(queue_state.index, 0u);
|
||||
const auto before_release = adapter->GetStats();
|
||||
EXPECT_EQ(before_release.submitted_buffers, 2u);
|
||||
EXPECT_EQ(before_release.pending_buffers, 1u);
|
||||
EXPECT_EQ(before_release.consumed_buffers, 0u);
|
||||
EXPECT_EQ(before_release.discarded_buffers, 1u);
|
||||
EXPECT_EQ(before_release.consumed_bytes, 0u);
|
||||
|
||||
controlled_view->ReleaseOne();
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(probe.mutex);
|
||||
EXPECT_TRUE(probe.cv.wait_for(lock, 2s, [&probe] { return probe.calls >= 1; }));
|
||||
}
|
||||
adapter->Shutdown();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(probe.mutex);
|
||||
EXPECT_EQ(probe.calls, 1);
|
||||
}
|
||||
ASSERT_EQ((*queue)->GetState(queue, &queue_state),
|
||||
opensl_abi::kResultSuccess);
|
||||
EXPECT_EQ(queue_state.count, 0u);
|
||||
EXPECT_EQ(queue_state.index, 1u);
|
||||
const auto after_release = adapter->GetStats();
|
||||
EXPECT_EQ(after_release.pending_buffers, 0u);
|
||||
EXPECT_EQ(after_release.consumed_buffers, 1u);
|
||||
EXPECT_EQ(after_release.discarded_buffers, 1u);
|
||||
EXPECT_EQ(after_release.consumed_bytes, bytes);
|
||||
EXPECT_TRUE(adapter->last_error().ok());
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, ClearCanRaceBorrowedBufferRelease) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
options.max_buffers = 1;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
options, &adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
const std::vector<std::int16_t> samples(32 * 2, 789);
|
||||
const auto bytes =
|
||||
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
|
||||
constexpr std::uint64_t kIterations = 64;
|
||||
for (std::uint64_t iteration = 0; iteration < kIterations; ++iteration) {
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
DelayedBufferRelease release(controlled_view);
|
||||
ASSERT_TRUE(release.WaitUntilBlocked());
|
||||
// Clear and ReleaseBuffer both run after this gate. Joining only after
|
||||
// Clear avoids ordering the ticket reset before the callback's first read.
|
||||
release.AllowRelease();
|
||||
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
|
||||
release.Join();
|
||||
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
|
||||
}
|
||||
adapter->Shutdown();
|
||||
const auto stats = adapter->GetStats();
|
||||
EXPECT_EQ(stats.submitted_buffers, kIterations);
|
||||
EXPECT_EQ(stats.consumed_buffers + stats.discarded_buffers, kIterations);
|
||||
EXPECT_EQ(stats.pending_buffers, 0u);
|
||||
EXPECT_TRUE(adapter->last_error().ok());
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, ShutdownWaitsForDelayedReleaseAfterClear) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
options.max_buffers = 1;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
options, &adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
const std::vector<std::int16_t> samples(32 * 2, 789);
|
||||
const auto bytes =
|
||||
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
DelayedBufferRelease release(controlled_view);
|
||||
ASSERT_TRUE(release.WaitUntilBlocked());
|
||||
ASSERT_EQ((*queue)->Clear(queue), opensl_abi::kResultSuccess);
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
|
||||
std::atomic<bool> shutdown_done{false};
|
||||
std::thread shutdown_thread([&] {
|
||||
adapter->Shutdown();
|
||||
shutdown_done.store(true);
|
||||
});
|
||||
EXPECT_TRUE(controlled_view->WaitUntilShutdownStarts());
|
||||
EXPECT_FALSE(shutdown_done.load());
|
||||
release.AllowRelease();
|
||||
release.Join();
|
||||
shutdown_thread.join();
|
||||
EXPECT_TRUE(shutdown_done.load());
|
||||
EXPECT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultPreconditionsViolated);
|
||||
const auto stats = adapter->GetStats();
|
||||
EXPECT_EQ(stats.pending_buffers, 0u);
|
||||
EXPECT_EQ(stats.consumed_buffers, 0u);
|
||||
EXPECT_EQ(stats.discarded_buffers, 2u);
|
||||
EXPECT_TRUE(adapter->last_error().ok());
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, FailedEnqueuePreservesCapacityAndCounters) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
options.max_buffers = 1;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
options, &adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
const std::vector<std::int16_t> samples(32 * 2, 789);
|
||||
const auto bytes =
|
||||
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
|
||||
controlled_view->RejectNextEnqueue();
|
||||
EXPECT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultResourceError);
|
||||
EXPECT_EQ(adapter->GetStats().submitted_buffers, 0u);
|
||||
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
controlled_view->ReleaseOne();
|
||||
const auto stats = adapter->GetStats();
|
||||
EXPECT_EQ(stats.submitted_buffers, 1u);
|
||||
EXPECT_EQ(stats.consumed_buffers, 1u);
|
||||
EXPECT_EQ(stats.discarded_buffers, 0u);
|
||||
EXPECT_EQ(stats.pending_buffers, 0u);
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, EnqueueCanReleaseBorrowedBufferSynchronously) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>(true);
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
options.max_buffers = 1;
|
||||
CallbackProbe probe;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled),
|
||||
options, &adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
|
||||
opensl_abi::kResultSuccess);
|
||||
const std::vector<std::int16_t> samples(32 * 2, 789);
|
||||
const auto bytes =
|
||||
static_cast<opensl_abi::Uint32>(samples.size() * sizeof(samples.front()));
|
||||
for (int enqueue = 0; enqueue < 2; ++enqueue) {
|
||||
ASSERT_EQ((*queue)->Enqueue(queue, samples.data(), bytes),
|
||||
opensl_abi::kResultSuccess);
|
||||
EXPECT_EQ(adapter->GetStats().pending_buffers, 0u);
|
||||
}
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(probe.mutex);
|
||||
EXPECT_TRUE(probe.cv.wait_for(lock, 2s, [&probe] { return probe.calls == 2; }));
|
||||
}
|
||||
adapter->Shutdown();
|
||||
const auto stats = adapter->GetStats();
|
||||
EXPECT_EQ(stats.submitted_buffers, 2u);
|
||||
EXPECT_EQ(stats.consumed_buffers, 2u);
|
||||
EXPECT_EQ(stats.discarded_buffers, 0u);
|
||||
EXPECT_EQ(stats.consumed_bytes, 2u * bytes);
|
||||
EXPECT_TRUE(adapter->last_error().ok());
|
||||
}
|
||||
|
||||
TEST(OpenSlQueueTest, RejectsOverflowAndShutsDownFailClosed) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
OpenSlSimpleBufferQueueOptions options;
|
||||
@@ -590,12 +876,12 @@ TEST(OpenSlQueueTest, ShutdownWaitsForInFlightCallback) {
|
||||
TEST(OpenSlQueueTest, GetStatePollsWhilePlaybackReleasesBuffers) {
|
||||
auto controlled = std::make_unique<ControlledAudioSink>();
|
||||
ControlledAudioSink* controlled_view = controlled.get();
|
||||
CallbackProbe probe;
|
||||
std::unique_ptr<OpenSlSimpleBufferQueueAdapter> adapter;
|
||||
ASSERT_TRUE(OpenSlSimpleBufferQueueAdapter::Create(std::move(controlled), {},
|
||||
&adapter)
|
||||
.ok());
|
||||
auto queue = adapter->interface();
|
||||
CallbackProbe probe;
|
||||
ASSERT_EQ((*queue)->RegisterCallback(queue, OpenSlQueueCallback, &probe),
|
||||
opensl_abi::kResultSuccess);
|
||||
|
||||
|
||||
@@ -2,12 +2,18 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace mocktail {
|
||||
namespace compat {
|
||||
namespace {
|
||||
|
||||
using AddressInfoOwner =
|
||||
std::unique_ptr<BionicAddressInfo, decltype(&BionicFreeAddressInfo)>;
|
||||
|
||||
TEST(BionicDnsRuntimeTest, BlocksOnlyCrashUploadInfrastructure) {
|
||||
EXPECT_TRUE(IsBlockedCrashReportUploadHost("upload.crashes.rbxinfra.com"));
|
||||
EXPECT_TRUE(IsBlockedCrashReportUploadHost("UPLOAD.CRASHES.RBXINFRA.COM."));
|
||||
@@ -58,6 +64,120 @@ TEST(BionicDnsRuntimeTest, RejectsMissingResultStorage) {
|
||||
BionicFreeAddressInfo(nullptr);
|
||||
}
|
||||
|
||||
TEST(BionicDnsRuntimeTest, GuestNumericServiceRejectsNamesAndAcceptsNumbers) {
|
||||
BionicAddressInfo hints;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = 0x00000008 | 0x00000004;
|
||||
BionicAddressInfo* result = nullptr;
|
||||
EXPECT_EQ(BionicGetAddressInfo("127.0.0.1", "http", &hints, &result),
|
||||
kBionicAddressInfoNameNotFound);
|
||||
EXPECT_EQ(result, nullptr);
|
||||
BionicFreeAddressInfo(result);
|
||||
|
||||
result = nullptr;
|
||||
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
|
||||
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
|
||||
ASSERT_EQ(status, 0);
|
||||
ASSERT_NE(result, nullptr);
|
||||
EXPECT_EQ(result->ai_flags, 0x0000000c);
|
||||
ASSERT_NE(result->ai_addr, nullptr);
|
||||
ASSERT_EQ(result->ai_family, AF_INET);
|
||||
EXPECT_EQ(
|
||||
ntohs(reinterpret_cast<const sockaddr_in*>(result->ai_addr)->sin_port),
|
||||
80);
|
||||
}
|
||||
|
||||
TEST(BionicDnsRuntimeTest, GuestAddressConfigurationAllowsNamedService) {
|
||||
addrinfo host_hints{};
|
||||
host_hints.ai_family = AF_INET;
|
||||
host_hints.ai_socktype = SOCK_STREAM;
|
||||
host_hints.ai_flags = AI_ADDRCONFIG | AI_NUMERICHOST;
|
||||
addrinfo* host_result = nullptr;
|
||||
const int host_status =
|
||||
::getaddrinfo("127.0.0.1", "http", &host_hints, &host_result);
|
||||
ASSERT_EQ(host_status, 0)
|
||||
<< "Host baseline requires a configured IPv4 interface and http service: "
|
||||
<< ::gai_strerror(host_status);
|
||||
ASSERT_NE(host_result, nullptr);
|
||||
::freeaddrinfo(host_result);
|
||||
|
||||
BionicAddressInfo hints;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = 0x00000400 | 0x00000004;
|
||||
BionicAddressInfo* result = nullptr;
|
||||
const int status = BionicGetAddressInfo("127.0.0.1", "http", &hints, &result);
|
||||
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
|
||||
ASSERT_EQ(status, 0);
|
||||
ASSERT_NE(result, nullptr);
|
||||
for (const BionicAddressInfo* current = result; current != nullptr;
|
||||
current = current->ai_next) {
|
||||
EXPECT_EQ(current->ai_flags, 0x00000404);
|
||||
EXPECT_EQ(current->ai_flags & 0x00000020, 0);
|
||||
ASSERT_NE(current->ai_addr, nullptr);
|
||||
ASSERT_EQ(current->ai_family, AF_INET);
|
||||
EXPECT_EQ(
|
||||
ntohs(reinterpret_cast<const sockaddr_in*>(current->ai_addr)->sin_port),
|
||||
80);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BionicDnsRuntimeTest, SupportedGuestFlagsRoundTrip) {
|
||||
// Android values are intentional; host AI_* constants are not guest hints.
|
||||
for (const int flags : {0x00000001, 0x00000002, 0x00000004, 0x00000008,
|
||||
0x00000400, 0x0000000f, 0x00000407, 0x0000040f}) {
|
||||
SCOPED_TRACE(flags);
|
||||
BionicAddressInfo hints;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = flags;
|
||||
BionicAddressInfo* result = nullptr;
|
||||
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
|
||||
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
|
||||
ASSERT_EQ(status, 0);
|
||||
ASSERT_NE(result, nullptr);
|
||||
for (const BionicAddressInfo* current = result; current != nullptr;
|
||||
current = current->ai_next) {
|
||||
EXPECT_EQ(current->ai_flags, flags);
|
||||
}
|
||||
if ((flags & 0x00000002) != 0) {
|
||||
ASSERT_NE(result->ai_canonname, nullptr);
|
||||
EXPECT_STREQ(result->ai_canonname, "127.0.0.1");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BionicDnsRuntimeTest, RejectsUnsupportedGuestFlagsBeforeHostResolution) {
|
||||
for (const int flags : {0x00000010, 0x00000020, 0x00000040, 0x00000080,
|
||||
0x00000100, 0x00000200, 0x00000800, 0x00001000,
|
||||
0x00000424, -1}) {
|
||||
SCOPED_TRACE(flags);
|
||||
BionicAddressInfo hints;
|
||||
hints.ai_family = AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = flags;
|
||||
BionicAddressInfo* result =
|
||||
reinterpret_cast<BionicAddressInfo*>(uintptr_t{1});
|
||||
const int status = BionicGetAddressInfo("127.0.0.1", "80", &hints, &result);
|
||||
EXPECT_EQ(status, 3);
|
||||
EXPECT_EQ(result, nullptr);
|
||||
BionicFreeAddressInfo(result);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(BionicDnsRuntimeTest, NullHintsPreserveLocalResolutionAndGuestFlags) {
|
||||
BionicAddressInfo* result = nullptr;
|
||||
const int status = BionicGetAddressInfo("127.0.0.1", "80", nullptr, &result);
|
||||
const AddressInfoOwner owner(result, &BionicFreeAddressInfo);
|
||||
ASSERT_EQ(status, 0);
|
||||
ASSERT_NE(result, nullptr);
|
||||
for (const BionicAddressInfo* current = result; current != nullptr;
|
||||
current = current->ai_next) {
|
||||
EXPECT_EQ(current->ai_flags & ~0x0000040f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace compat
|
||||
} // namespace mocktail
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "update/compatibility_catalog.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include "compat/guest_abi.h"
|
||||
|
||||
namespace mocktail::update {
|
||||
namespace {
|
||||
|
||||
class CompatibilityCatalogTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
char pattern[] = "/tmp/mocktail_catalog_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
ASSERT_NE(created, nullptr);
|
||||
directory = created;
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
std::error_code ignored;
|
||||
if (!directory.empty()) std::filesystem::remove_all(directory, ignored);
|
||||
}
|
||||
|
||||
nlohmann::json ValidProfile() {
|
||||
return {{"status", "supported"},
|
||||
{"default_allowed", true},
|
||||
{"allow_legacy_binary_patches", false},
|
||||
{"abi", compat::kGuestAbi},
|
||||
{"version_name", "1.2.3"},
|
||||
{"version_code", 123},
|
||||
{"elf_build_id", std::string(40, 'a')}};
|
||||
}
|
||||
|
||||
CompatibilityCatalogResult Load(const nlohmann::json& document) {
|
||||
const auto path = directory / "catalog.json";
|
||||
std::ofstream(path) << document.dump();
|
||||
return LoadCompatibilityCatalog(path);
|
||||
}
|
||||
|
||||
std::filesystem::path directory;
|
||||
};
|
||||
|
||||
TEST_F(CompatibilityCatalogTest, LoadsValidSupportedProfile) {
|
||||
const auto catalog =
|
||||
Load({{"schema_version", 1}, {"profiles", {ValidProfile()}}});
|
||||
ASSERT_TRUE(catalog) << catalog.error;
|
||||
ASSERT_EQ(catalog.profiles.size(), 1u);
|
||||
EXPECT_EQ(catalog.profiles.front().version_code, 123u);
|
||||
}
|
||||
|
||||
TEST_F(CompatibilityCatalogTest, RejectsInvalidSchemaTypesWithoutTerminating) {
|
||||
for (const auto& version :
|
||||
nlohmann::json::array({nullptr, true, "1", 1.0, 4294967297ULL,
|
||||
nlohmann::json::object()})) {
|
||||
SCOPED_TRACE(version.dump());
|
||||
const auto catalog =
|
||||
Load({{"schema_version", version}, {"profiles", {ValidProfile()}}});
|
||||
EXPECT_FALSE(catalog);
|
||||
EXPECT_FALSE(catalog.error.empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CompatibilityCatalogTest, RejectsInvalidPolicyTypesWithoutTerminating) {
|
||||
const std::pair<const char*, nlohmann::json> invalid[] = {
|
||||
{"status", true},
|
||||
{"status", 1},
|
||||
{"status", nullptr},
|
||||
{"default_allowed", "true"},
|
||||
{"default_allowed", 1},
|
||||
{"default_allowed", nullptr},
|
||||
{"allow_legacy_binary_patches", "false"},
|
||||
{"allow_legacy_binary_patches", 0},
|
||||
{"allow_legacy_binary_patches", nullptr}};
|
||||
for (const auto& [name, value] : invalid) {
|
||||
SCOPED_TRACE(name);
|
||||
SCOPED_TRACE(value.dump());
|
||||
auto profile = ValidProfile();
|
||||
profile[name] = value;
|
||||
const auto catalog = Load({{"schema_version", 1}, {"profiles", {profile}}});
|
||||
EXPECT_FALSE(catalog);
|
||||
EXPECT_FALSE(catalog.error.empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CompatibilityCatalogTest, MissingPolicyFieldsCannotAuthorizeAPayload) {
|
||||
for (const char* name :
|
||||
{"status", "default_allowed", "allow_legacy_binary_patches"}) {
|
||||
SCOPED_TRACE(name);
|
||||
auto profile = ValidProfile();
|
||||
profile.erase(name);
|
||||
const auto catalog = Load({{"schema_version", 1}, {"profiles", {profile}}});
|
||||
EXPECT_FALSE(catalog);
|
||||
EXPECT_TRUE(catalog.profiles.empty());
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(CompatibilityCatalogTest, FiltersOtherArchitectures) {
|
||||
auto other = ValidProfile();
|
||||
other["abi"] = compat::kGuestAbi == "x86_64" ? "arm64-v8a" : "x86_64";
|
||||
other["version_code"] = 456;
|
||||
const auto catalog =
|
||||
Load({{"schema_version", 1}, {"profiles", {other, ValidProfile()}}});
|
||||
ASSERT_TRUE(catalog) << catalog.error;
|
||||
ASSERT_EQ(catalog.profiles.size(), 1u);
|
||||
EXPECT_EQ(catalog.profiles.front().version_code, 123u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::update
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
@@ -360,5 +361,273 @@ TEST(Etc2DecoderTest, RejectsShortBuffers) {
|
||||
output.data(), output.size()));
|
||||
}
|
||||
|
||||
namespace reference {
|
||||
|
||||
constexpr std::array<int, 8> kDistance = {3, 6, 11, 16, 23, 32, 41, 64};
|
||||
|
||||
constexpr int kEtc1Modifiers[8][4] = {
|
||||
{2, 8, -2, -8}, {5, 17, -5, -17}, {9, 29, -9, -29},
|
||||
{13, 42, -13, -42}, {18, 60, -18, -60}, {24, 80, -24, -80},
|
||||
{33, 106, -33, -106}, {47, 183, -47, -183},
|
||||
};
|
||||
|
||||
constexpr int kEacModifiers[16][8] = {
|
||||
{-3, -6, -9, -15, 2, 5, 8, 14}, {-3, -7, -10, -13, 2, 6, 9, 12},
|
||||
{-2, -5, -8, -13, 1, 4, 7, 12}, {-2, -4, -6, -13, 1, 3, 5, 12},
|
||||
{-3, -6, -8, -12, 2, 5, 7, 11}, {-3, -7, -9, -11, 2, 6, 8, 10},
|
||||
{-4, -7, -8, -11, 3, 6, 7, 10}, {-3, -5, -8, -11, 2, 4, 7, 10},
|
||||
{-2, -6, -8, -10, 1, 5, 7, 9}, {-2, -5, -8, -10, 1, 4, 7, 9},
|
||||
{-2, -4, -8, -10, 1, 3, 7, 9}, {-2, -5, -7, -10, 1, 4, 6, 9},
|
||||
{-3, -4, -7, -10, 2, 3, 6, 9}, {-1, -2, -3, -10, 0, 1, 2, 9},
|
||||
{-4, -6, -8, -9, 3, 5, 7, 8}, {-3, -5, -7, -9, 2, 4, 6, 8},
|
||||
};
|
||||
|
||||
inline std::uint8_t Clamp255(int value) {
|
||||
return static_cast<std::uint8_t>(std::clamp(value, 0, 255));
|
||||
}
|
||||
|
||||
inline int Expand4(int value) { return (value << 4) | value; }
|
||||
inline int Expand5(int value) { return (value << 3) | (value >> 2); }
|
||||
inline int Expand6(int value) { return (value << 2) | (value >> 4); }
|
||||
inline int Expand7(int value) { return (value << 1) | (value >> 6); }
|
||||
|
||||
inline void DecodeRgbBlock(const std::uint8_t* b, bool punchthrough,
|
||||
std::array<std::uint8_t, 64>* out) {
|
||||
static constexpr int kDelta[8] = {0, 1, 2, 3, -4, -3, -2, -1};
|
||||
const bool diff_or_opaque = (b[3] & 2) != 0;
|
||||
const bool opaque = !punchthrough || diff_or_opaque;
|
||||
const int msb = (b[4] << 8) | b[5];
|
||||
const int lsb = (b[6] << 8) | b[7];
|
||||
auto index_of = [msb, lsb](int texel) {
|
||||
return (((msb >> texel) & 1) << 1) | ((lsb >> texel) & 1);
|
||||
};
|
||||
auto write = [&](int texel, int r, int g, int bl, int a) {
|
||||
(*out)[texel * 4] = Clamp255(r);
|
||||
(*out)[texel * 4 + 1] = Clamp255(g);
|
||||
(*out)[texel * 4 + 2] = Clamp255(bl);
|
||||
(*out)[texel * 4 + 3] = static_cast<std::uint8_t>(a);
|
||||
};
|
||||
|
||||
const int r_sum = (b[0] >> 3) + kDelta[b[0] & 7];
|
||||
const int g_sum = (b[1] >> 3) + kDelta[b[1] & 7];
|
||||
const int b_sum = (b[2] >> 3) + kDelta[b[2] & 7];
|
||||
const bool individual = !punchthrough && !diff_or_opaque;
|
||||
|
||||
if (!individual && (r_sum < 0 || r_sum > 31 || g_sum < 0 || g_sum > 31)) {
|
||||
std::array<std::array<int, 3>, 4> paint{};
|
||||
if (r_sum < 0 || r_sum > 31) {
|
||||
const std::array<int, 3> c1 = {
|
||||
Expand4((((b[0] >> 3) & 3) << 2) | (b[0] & 3)),
|
||||
Expand4(b[1] >> 4), Expand4(b[1] & 15)};
|
||||
const std::array<int, 3> c2 = {Expand4(b[2] >> 4), Expand4(b[2] & 15),
|
||||
Expand4(b[3] >> 4)};
|
||||
const int d = kDistance[(((b[3] >> 2) & 3) << 1) | (b[3] & 1)];
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
paint[0][c] = c1[c];
|
||||
paint[1][c] = c2[c] + d;
|
||||
paint[2][c] = c2[c];
|
||||
paint[3][c] = c2[c] - d;
|
||||
}
|
||||
} else {
|
||||
const std::array<int, 3> c1 = {
|
||||
Expand4((b[0] >> 3) & 15),
|
||||
Expand4(((b[0] & 7) << 1) | ((b[1] >> 4) & 1)),
|
||||
Expand4((b[1] & 8) | ((b[1] & 3) << 1) | (b[2] >> 7))};
|
||||
const std::array<int, 3> c2 = {
|
||||
Expand4((b[2] >> 3) & 15), Expand4(((b[2] & 7) << 1) | (b[3] >> 7)),
|
||||
Expand4((b[3] >> 3) & 15)};
|
||||
const int v1 = (c1[0] << 16) | (c1[1] << 8) | c1[2];
|
||||
const int v2 = (c2[0] << 16) | (c2[1] << 8) | c2[2];
|
||||
const int d =
|
||||
kDistance[(b[3] & 4) | ((b[3] & 1) << 1) | (v1 >= v2 ? 1 : 0)];
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
paint[0][c] = c1[c] + d;
|
||||
paint[1][c] = c1[c] - d;
|
||||
paint[2][c] = c2[c] + d;
|
||||
paint[3][c] = c2[c] - d;
|
||||
}
|
||||
}
|
||||
for (int texel = 0; texel < 16; ++texel) {
|
||||
const int index = index_of(texel);
|
||||
if (!opaque && index == 2) {
|
||||
write(texel, 0, 0, 0, 0);
|
||||
} else {
|
||||
write(texel, paint[index][0], paint[index][1], paint[index][2], 255);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!individual && (b_sum < 0 || b_sum > 31)) {
|
||||
const int ro = Expand6((b[0] >> 1) & 63);
|
||||
const int go = Expand7(((b[0] & 1) << 6) | ((b[1] >> 1) & 63));
|
||||
const int bo = Expand6(((b[1] & 1) << 5) | (b[2] & 0x18) |
|
||||
((b[2] & 3) << 1) | (b[3] >> 7));
|
||||
const int rh = Expand6(((b[3] & 0x7c) >> 1) | (b[3] & 1));
|
||||
const int gh = Expand7(b[4] >> 1);
|
||||
const int bh = Expand6(((b[4] & 1) << 5) | (b[5] >> 3));
|
||||
const int rv = Expand6(((b[5] & 7) << 3) | (b[6] >> 5));
|
||||
const int gv = Expand7(((b[6] & 31) << 2) | (b[7] >> 6));
|
||||
const int bv = Expand6(b[7] & 63);
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
write(x * 4 + y, (x * (rh - ro) + y * (rv - ro) + 4 * ro + 2) >> 2,
|
||||
(x * (gh - go) + y * (gv - go) + 4 * go + 2) >> 2,
|
||||
(x * (bh - bo) + y * (bv - bo) + 4 * bo + 2) >> 2, 255);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
std::array<std::array<int, 3>, 2> base{};
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
if (individual) {
|
||||
base[0][c] = Expand4(b[c] >> 4);
|
||||
base[1][c] = Expand4(b[c] & 15);
|
||||
} else {
|
||||
base[0][c] = Expand5(b[c] >> 3);
|
||||
base[1][c] = Expand5((b[c] >> 3) + kDelta[b[c] & 7]);
|
||||
}
|
||||
}
|
||||
const int tables[2] = {b[3] >> 5, (b[3] >> 2) & 7};
|
||||
const bool flipped = (b[3] & 1) != 0;
|
||||
for (int x = 0; x < 4; ++x) {
|
||||
for (int y = 0; y < 4; ++y) {
|
||||
const int texel = x * 4 + y;
|
||||
const int index = index_of(texel);
|
||||
if (!opaque && index == 2) {
|
||||
write(texel, 0, 0, 0, 0);
|
||||
continue;
|
||||
}
|
||||
const int sub = flipped ? (y >= 2 ? 1 : 0) : (x >= 2 ? 1 : 0);
|
||||
const int modifier =
|
||||
(!opaque && index == 0) ? 0 : kEtc1Modifiers[tables[sub]][index];
|
||||
write(texel, base[sub][0] + modifier, base[sub][1] + modifier,
|
||||
base[sub][2] + modifier, 255);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inline int EacIndex(const std::uint8_t* b, int texel) {
|
||||
std::uint64_t bits = 0;
|
||||
for (int i = 2; i < 8; ++i) {
|
||||
bits = (bits << 8) | b[i];
|
||||
}
|
||||
return static_cast<int>((bits >> (45 - texel * 3)) & 7);
|
||||
}
|
||||
|
||||
inline int EacModifier(const std::uint8_t* b, int texel) {
|
||||
return kEacModifiers[b[1] & 15][EacIndex(b, texel)];
|
||||
}
|
||||
|
||||
inline std::uint16_t DecodeR11(const std::uint8_t* b, int texel, bool is_signed) {
|
||||
const int multiplier = b[1] >> 4;
|
||||
const int modifier = EacModifier(b, texel);
|
||||
const int scaled = multiplier != 0 ? modifier * multiplier * 8 : modifier;
|
||||
if (!is_signed) {
|
||||
const int value = std::clamp(b[0] * 8 + 4 + scaled, 0, 2047);
|
||||
return static_cast<std::uint16_t>((value << 5) | (value >> 6));
|
||||
}
|
||||
const int base = std::max(-127, static_cast<int>(static_cast<std::int8_t>(b[0])));
|
||||
const int value = std::clamp(base * 8 + scaled, -1023, 1023);
|
||||
const int magnitude = value < 0 ? -value : value;
|
||||
const int expanded = (magnitude << 5) | (magnitude >> 5);
|
||||
return static_cast<std::uint16_t>(
|
||||
static_cast<std::int16_t>(value < 0 ? -expanded : expanded));
|
||||
}
|
||||
|
||||
inline void DecodeReference(EtcFormat format, const std::uint8_t* source,
|
||||
std::uint32_t width, std::uint32_t height,
|
||||
std::uint8_t* destination) {
|
||||
const std::size_t texel_bytes = EtcDecodedTexelBytes(format);
|
||||
const std::size_t block_bytes = EtcBlockBytes(format);
|
||||
const std::uint64_t blocks_wide = (static_cast<std::uint64_t>(width) + 3) / 4;
|
||||
const std::uint64_t blocks_high = (static_cast<std::uint64_t>(height) + 3) / 4;
|
||||
std::array<std::uint8_t, 64> rgba{};
|
||||
|
||||
for (std::uint64_t by = 0; by < blocks_high; ++by) {
|
||||
for (std::uint64_t bx = 0; bx < blocks_wide; ++bx) {
|
||||
const std::uint8_t* block = source + (by * blocks_wide + bx) * block_bytes;
|
||||
if (format == EtcFormat::kEtc2Rgb8 || format == EtcFormat::kEtc2Rgb8A1) {
|
||||
DecodeRgbBlock(block, format == EtcFormat::kEtc2Rgb8A1, &rgba);
|
||||
} else if (format == EtcFormat::kEtc2Rgba8) {
|
||||
DecodeRgbBlock(block + 8, false, &rgba);
|
||||
}
|
||||
for (std::uint32_t y = 0; y < 4 && by * 4 + y < height; ++y) {
|
||||
for (std::uint32_t x = 0; x < 4 && bx * 4 + x < width; ++x) {
|
||||
const int texel = static_cast<int>(x * 4 + y);
|
||||
std::uint8_t* out = destination + ((by * 4 + y) * width + bx * 4 + x) * texel_bytes;
|
||||
switch (format) {
|
||||
case EtcFormat::kEtc2Rgb8:
|
||||
case EtcFormat::kEtc2Rgb8A1:
|
||||
case EtcFormat::kEtc2Rgba8:
|
||||
std::copy_n(rgba.data() + texel * 4, 4, out);
|
||||
if (format == EtcFormat::kEtc2Rgba8) {
|
||||
const int alpha = block[0] + EacModifier(block, texel) * (block[1] >> 4);
|
||||
out[3] = Clamp255(alpha);
|
||||
}
|
||||
break;
|
||||
case EtcFormat::kEacR11:
|
||||
case EtcFormat::kEacR11Signed:
|
||||
case EtcFormat::kEacRg11:
|
||||
case EtcFormat::kEacRg11Signed: {
|
||||
const bool is_signed = format == EtcFormat::kEacR11Signed || format == EtcFormat::kEacRg11Signed;
|
||||
const int channels = static_cast<int>(texel_bytes / 2);
|
||||
for (int channel = 0; channel < channels; ++channel) {
|
||||
const std::uint16_t value = DecodeR11(block + channel * 8, texel, is_signed);
|
||||
out[channel * 2] = static_cast<std::uint8_t>(value);
|
||||
out[channel * 2 + 1] = static_cast<std::uint8_t>(value >> 8);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reference
|
||||
|
||||
TEST(Etc2DecoderTest, ComparesBitExactAgainstScalarReferenceAcrossAllFormats) {
|
||||
const EtcFormat formats[] = {
|
||||
EtcFormat::kEtc2Rgb8,
|
||||
EtcFormat::kEtc2Rgb8A1,
|
||||
EtcFormat::kEtc2Rgba8,
|
||||
EtcFormat::kEacR11,
|
||||
EtcFormat::kEacR11Signed,
|
||||
EtcFormat::kEacRg11,
|
||||
EtcFormat::kEacRg11Signed,
|
||||
};
|
||||
const struct {
|
||||
std::uint32_t width;
|
||||
std::uint32_t height;
|
||||
} sizes[] = {
|
||||
{1, 1},
|
||||
{4, 4},
|
||||
{7, 5},
|
||||
{16, 16},
|
||||
{67, 59},
|
||||
{128, 128},
|
||||
};
|
||||
for (std::size_t f = 0; f < std::size(formats); ++f) {
|
||||
const EtcFormat format = formats[f];
|
||||
for (std::size_t s = 0; s < std::size(sizes); ++s) {
|
||||
const std::uint32_t width = sizes[s].width;
|
||||
const std::uint32_t height = sizes[s].height;
|
||||
const auto source = RandomBlocks(
|
||||
format, width, height,
|
||||
static_cast<std::uint32_t>(0xABCD1234 + f * 100 + s));
|
||||
const auto actual = Decode(format, source, width, height);
|
||||
std::vector<std::uint8_t> expected(actual.size(), 0);
|
||||
reference::DecodeReference(format, source.data(), width, height,
|
||||
expected.data());
|
||||
EXPECT_EQ(actual, expected)
|
||||
<< "Mismatch in format=" << static_cast<int>(format)
|
||||
<< " width=" << width << " height=" << height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace mocktail::graphics
|
||||
@@ -37,12 +37,14 @@ finish_args = set(manifest["finish-args"])
|
||||
required_permissions = {
|
||||
"--share=network",
|
||||
"--socket=wayland",
|
||||
"--socket=fallback-x11",
|
||||
"--socket=x11",
|
||||
"--socket=pulseaudio",
|
||||
"--device=dri",
|
||||
"--filesystem=xdg-run/discord-ipc-0:rw",
|
||||
}
|
||||
assert required_permissions <= finish_args
|
||||
# Keep NVIDIA's XWayland fallback available even when Wayland is present.
|
||||
assert "--socket=fallback-x11" not in finish_args
|
||||
assert not any(argument.startswith("--filesystem=host") for argument in finish_args)
|
||||
assert not any(argument == "--filesystem=home" for argument in finish_args)
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
|
||||
namespace jnivm {
|
||||
namespace {
|
||||
|
||||
class JniFieldsTest : public testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
env = vm.GetJNIEnv();
|
||||
clazz = env->FindClass("test/Fields");
|
||||
}
|
||||
|
||||
std::string ReadString(jobject value) {
|
||||
const auto string = static_cast<jstring>(value);
|
||||
const char* bytes = env->GetStringUTFChars(string, nullptr);
|
||||
const std::string result = bytes ? bytes : "";
|
||||
if (bytes) env->ReleaseStringUTFChars(string, bytes);
|
||||
return result;
|
||||
}
|
||||
|
||||
VM vm;
|
||||
JNIEnv* env = nullptr;
|
||||
jclass clazz = nullptr;
|
||||
};
|
||||
|
||||
TEST_F(JniFieldsTest, StaticFieldOwnsItsValueAfterLocalReferenceIsDeleted) {
|
||||
const auto field =
|
||||
env->GetStaticFieldID(clazz, "saved", "Ljava/lang/String;");
|
||||
const auto value = env->NewStringUTF("retained");
|
||||
env->SetStaticObjectField(clazz, field, value);
|
||||
env->DeleteLocalRef(value);
|
||||
const auto other = env->NewStringUTF("replacement");
|
||||
const auto saved = env->GetStaticObjectField(clazz, field);
|
||||
EXPECT_EQ(ReadString(saved), "retained");
|
||||
env->DeleteLocalRef(saved);
|
||||
env->DeleteLocalRef(other);
|
||||
env->SetStaticObjectField(clazz, field, nullptr);
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, ReadingStaticFieldReturnsAnIndependentLocalReference) {
|
||||
const auto field = env->GetStaticFieldID(clazz, "read", "Ljava/lang/String;");
|
||||
const auto value = env->NewStringUTF("retained");
|
||||
env->SetStaticObjectField(clazz, field, value);
|
||||
const auto first = env->GetStaticObjectField(clazz, field);
|
||||
env->DeleteLocalRef(first);
|
||||
const auto second = env->GetStaticObjectField(clazz, field);
|
||||
EXPECT_EQ(ReadString(second), "retained");
|
||||
env->SetStaticObjectField(clazz, field, nullptr);
|
||||
EXPECT_EQ(ReadString(second), "retained");
|
||||
env->DeleteLocalRef(second);
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, FieldIdsOwnTemporaryNames) {
|
||||
auto object = env->AllocObject(clazz);
|
||||
char name[] = "original";
|
||||
const auto field = env->GetFieldID(clazz, name, "I");
|
||||
const auto static_field =
|
||||
env->GetStaticFieldID(clazz, name, "Ljava/lang/String;");
|
||||
std::memcpy(name, "modified", sizeof(name));
|
||||
env->SetIntField(object, field, 42);
|
||||
EXPECT_EQ(env->GetIntField(object, env->GetFieldID(clazz, "original", "I")),
|
||||
42);
|
||||
const auto value = env->NewStringUTF("stable");
|
||||
env->SetStaticObjectField(clazz, static_field, value);
|
||||
const auto saved = env->GetStaticObjectField(
|
||||
clazz, env->GetStaticFieldID(clazz, "original", "Ljava/lang/String;"));
|
||||
EXPECT_EQ(ReadString(saved), "stable");
|
||||
env->DeleteLocalRef(saved);
|
||||
env->DeleteLocalRef(value);
|
||||
env->SetStaticObjectField(clazz, static_field, nullptr);
|
||||
env->DeleteLocalRef(object);
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, ReclaimsObjectFieldReferencesAcrossManyAllocations) {
|
||||
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
|
||||
for (int iteration = 0; iteration < 110000; ++iteration) {
|
||||
const auto parent = env->AllocObject(clazz);
|
||||
const auto child = env->NewStringUTF("child");
|
||||
ASSERT_NE(parent, nullptr) << iteration;
|
||||
ASSERT_NE(child, nullptr) << iteration;
|
||||
env->SetObjectField(parent, field, child);
|
||||
env->DeleteLocalRef(child);
|
||||
env->DeleteLocalRef(parent);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, ReleasingParentPreservesAnExplicitGlobalChildReference) {
|
||||
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
|
||||
const auto parent = env->AllocObject(clazz);
|
||||
const auto child = env->NewStringUTF("survives");
|
||||
const auto global = env->NewGlobalRef(child);
|
||||
env->SetObjectField(parent, field, child);
|
||||
env->DeleteLocalRef(child);
|
||||
env->DeleteLocalRef(parent);
|
||||
EXPECT_EQ(ReadString(global), "survives");
|
||||
env->DeleteGlobalRef(global);
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, InstanceGetterReturnsAnIndependentLocalReference) {
|
||||
const auto field = env->GetFieldID(clazz, "child", "Ljava/lang/String;");
|
||||
const auto parent = env->AllocObject(clazz);
|
||||
const auto child = env->NewStringUTF("survives");
|
||||
env->SetObjectField(parent, field, child);
|
||||
env->DeleteLocalRef(child);
|
||||
const auto result = env->CallObjectMethod(
|
||||
parent, env->GetMethodID(clazz, "getChild", "()Ljava/lang/String;"));
|
||||
env->DeleteLocalRef(parent);
|
||||
EXPECT_EQ(ReadString(result), "survives");
|
||||
env->DeleteLocalRef(result);
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, StaticCallbackSetterOwnsItsImplementation) {
|
||||
const auto interface =
|
||||
env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
||||
const auto callback_class =
|
||||
env->FindClass("com/roblox/engine/jni/EngineJavaCallback2");
|
||||
const auto callback = env->AllocObject(callback_class);
|
||||
const auto marker = env->GetFieldID(callback_class, "marker", "I");
|
||||
env->SetIntField(callback, marker, 73);
|
||||
const auto setter =
|
||||
env->GetStaticMethodID(interface, "setImplementation",
|
||||
"(Lcom/roblox/engine/jni/EngineJavaCallback2;)V");
|
||||
env->CallStaticVoidMethod(interface, setter, callback);
|
||||
env->DeleteLocalRef(callback);
|
||||
const auto other = env->AllocObject(callback_class);
|
||||
env->SetIntField(other, marker, 99);
|
||||
const auto getter =
|
||||
env->GetStaticMethodID(interface, "getImplementation",
|
||||
"()Lcom/roblox/engine/jni/EngineJavaCallback2;");
|
||||
const auto saved = env->CallStaticObjectMethod(interface, getter);
|
||||
EXPECT_EQ(env->GetIntField(saved, marker), 73);
|
||||
env->DeleteLocalRef(saved);
|
||||
env->DeleteLocalRef(other);
|
||||
env->CallStaticVoidMethod(interface, setter, static_cast<jobject>(nullptr));
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, ReclaimsObjectArrayReferencesAcrossManyAllocations) {
|
||||
for (int iteration = 0; iteration < 110000; ++iteration) {
|
||||
const auto child = env->NewStringUTF("child");
|
||||
ASSERT_NE(child, nullptr) << iteration;
|
||||
const auto array = env->NewObjectArray(2, clazz, child);
|
||||
ASSERT_NE(array, nullptr);
|
||||
env->DeleteLocalRef(child);
|
||||
const auto element = env->GetObjectArrayElement(array, 1);
|
||||
EXPECT_EQ(ReadString(element), "child");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(array);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, PopLocalFramePromotesOnlyOneResultReference) {
|
||||
for (int iteration = 0; iteration < 110000; ++iteration) {
|
||||
ASSERT_EQ(env->PushLocalFrame(3), JNI_OK);
|
||||
const auto value = env->NewStringUTF("result");
|
||||
ASSERT_NE(value, nullptr) << iteration;
|
||||
env->NewLocalRef(value);
|
||||
env->NewLocalRef(value);
|
||||
const auto result = env->PopLocalFrame(value);
|
||||
ASSERT_EQ(ReadString(result), "result");
|
||||
env->DeleteLocalRef(result);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniFieldsTest, ConcurrentPrimitiveFieldWritesPreserveAllValues) {
|
||||
constexpr int kThreads = 4;
|
||||
constexpr int kFields = 1000;
|
||||
std::vector<std::array<jfieldID, 3>> ids;
|
||||
for (int i = 0; i < kThreads * kFields; ++i) {
|
||||
const std::string name = std::to_string(i);
|
||||
ids.push_back({env->GetFieldID(clazz, (name + "i").c_str(), "I"),
|
||||
env->GetFieldID(clazz, (name + "b").c_str(), "Z"),
|
||||
env->GetFieldID(clazz, (name + "f").c_str(), "F")});
|
||||
}
|
||||
const auto local = env->AllocObject(clazz);
|
||||
const auto object = env->NewGlobalRef(local);
|
||||
env->DeleteLocalRef(local);
|
||||
std::atomic<int> ready{0};
|
||||
std::array<std::thread, kThreads> workers;
|
||||
for (int thread = 0; thread < kThreads; ++thread) {
|
||||
workers[thread] = std::thread([&, thread] {
|
||||
JNIEnv* worker = nullptr;
|
||||
const jint attached = vm.GetJavaVM()->AttachCurrentThread(
|
||||
reinterpret_cast<void**>(&worker), nullptr);
|
||||
ready.fetch_add(1);
|
||||
while (ready.load() != kThreads) std::this_thread::yield();
|
||||
ASSERT_EQ(attached, JNI_OK);
|
||||
for (int i = thread * kFields; i < (thread + 1) * kFields; ++i) {
|
||||
worker->SetIntField(object, ids[i][0], i);
|
||||
worker->SetBooleanField(object, ids[i][1], JNI_TRUE);
|
||||
worker->SetFloatField(object, ids[i][2], static_cast<float>(i));
|
||||
}
|
||||
EXPECT_EQ(vm.GetJavaVM()->DetachCurrentThread(), JNI_OK);
|
||||
});
|
||||
}
|
||||
for (auto& worker : workers) worker.join();
|
||||
for (int i = 0; i < kThreads * kFields; ++i) {
|
||||
EXPECT_EQ(env->GetIntField(object, ids[i][0]), i);
|
||||
EXPECT_EQ(env->GetBooleanField(object, ids[i][1]), JNI_TRUE);
|
||||
EXPECT_EQ(env->GetFloatField(object, ids[i][2]), static_cast<float>(i));
|
||||
}
|
||||
env->DeleteGlobalRef(object);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace jnivm
|
||||
+297
-3
@@ -725,6 +725,27 @@ TEST_F(JniVmTest, FindClassReturnsRegisteredInstance) {
|
||||
EXPECT_EQ(registered.get(), found.get());
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, RepeatedClassLookupsReuseTheSameHandle) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass first = env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface");
|
||||
ASSERT_NE(first, nullptr);
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
ASSERT_NE(string_class, nullptr);
|
||||
// Exceeds the JNI segment capacity (100000); without interned class handles
|
||||
// every lookup consumes a permanent slot and the table runs out here.
|
||||
constexpr int kIterations = 120000;
|
||||
for (int i = 0; i < kIterations; ++i) {
|
||||
ASSERT_EQ(env->FindClass("com/roblox/engine/jni/NativeGLJavaInterface"),
|
||||
first);
|
||||
jstring value = env->NewStringUTF("probe");
|
||||
ASSERT_NE(value, nullptr);
|
||||
jclass value_class = env->GetObjectClass(value);
|
||||
ASSERT_EQ(value_class, string_class);
|
||||
ASSERT_EQ(env->GetObjectClass(value), value_class);
|
||||
env->DeleteLocalRef(value);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, RegisteredMethodIsFound) {
|
||||
auto cls = vm_->RegisterClass("rbx/JNIRobloxSettings");
|
||||
cls->RegisterMethod("nativeInitClientSettings", "()V",
|
||||
@@ -1088,6 +1109,279 @@ TEST_F(JniVmTest, FloatArrayElementsAreMutable) {
|
||||
env->ReleaseFloatArrayElements(array, elements, 0);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalDeletionAndSlotReuse) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jstring initial = env->NewStringUTF("original initializer");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
||||
ASSERT_NE(array, nullptr);
|
||||
env->DeleteLocalRef(initial);
|
||||
|
||||
jstring replacement = env->NewStringUTF("new allocation");
|
||||
ASSERT_NE(replacement, nullptr);
|
||||
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_NE(element, replacement);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "original initializer");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(replacement);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayDeletionReleasesSetElementSlot) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
|
||||
ASSERT_NE(array, nullptr);
|
||||
jstring element = env->NewStringUTF("held by array");
|
||||
ASSERT_NE(element, nullptr);
|
||||
env->SetObjectArrayElement(array, 0, element);
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(array);
|
||||
|
||||
// Only compare the saved handle value; never call JNI with a deleted ref.
|
||||
jstring replacement = env->NewStringUTF("reuses released slot");
|
||||
ASSERT_NE(replacement, nullptr);
|
||||
EXPECT_EQ(replacement, element);
|
||||
EXPECT_EQ(ReadJavaString(env, replacement), "reuses released slot");
|
||||
env->DeleteLocalRef(replacement);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayInitializerSurvivesLocalFramePop) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
ASSERT_EQ(env->PushLocalFrame(4), JNI_OK);
|
||||
jstring initial = env->NewStringUTF("frame initializer");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
||||
ASSERT_NE(array, nullptr);
|
||||
array = static_cast<jobjectArray>(env->PopLocalFrame(array));
|
||||
ASSERT_NE(array, nullptr);
|
||||
|
||||
jstring replacement = env->NewStringUTF("after frame pop");
|
||||
ASSERT_NE(replacement, nullptr);
|
||||
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_NE(element, replacement);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "frame initializer");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(array);
|
||||
jstring recycled = env->NewStringUTF("released frame initializer");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(replacement);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayRepeatedInitializerOwnsEveryPosition) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jstring initial = env->NewStringUTF("three retained positions");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray array = env->NewObjectArray(3, string_class, initial);
|
||||
ASSERT_NE(array, nullptr);
|
||||
env->DeleteLocalRef(initial);
|
||||
env->SetObjectArrayElement(array, 0, nullptr);
|
||||
env->SetObjectArrayElement(array, 1, nullptr);
|
||||
|
||||
jstring replacement = env->NewStringUTF("last position still owns original");
|
||||
ASSERT_NE(replacement, nullptr);
|
||||
auto remaining = static_cast<jstring>(env->GetObjectArrayElement(array, 2));
|
||||
ASSERT_NE(remaining, nullptr);
|
||||
EXPECT_NE(remaining, replacement);
|
||||
EXPECT_EQ(ReadJavaString(env, remaining), "three retained positions");
|
||||
env->DeleteLocalRef(remaining);
|
||||
env->SetObjectArrayElement(array, 2, nullptr);
|
||||
jstring recycled = env->NewStringUTF("last position released");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(array);
|
||||
EXPECT_EQ(ReadJavaString(env, recycled), "last position released");
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(replacement);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayReplacementReleasesSameDifferentAndNullElements) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jobjectArray array = env->NewObjectArray(1, string_class, nullptr);
|
||||
ASSERT_NE(array, nullptr);
|
||||
jstring first = env->NewStringUTF("first");
|
||||
jstring second = env->NewStringUTF("second");
|
||||
ASSERT_NE(first, nullptr);
|
||||
ASSERT_NE(second, nullptr);
|
||||
env->SetObjectArrayElement(array, 0, first);
|
||||
env->SetObjectArrayElement(array, 0, first);
|
||||
env->DeleteLocalRef(first);
|
||||
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "first");
|
||||
env->DeleteLocalRef(element);
|
||||
|
||||
env->SetObjectArrayElement(array, 0, second);
|
||||
jstring first_reuse = env->NewStringUTF("first slot reused");
|
||||
ASSERT_NE(first_reuse, nullptr);
|
||||
EXPECT_EQ(first_reuse, first);
|
||||
env->DeleteLocalRef(second);
|
||||
element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "second");
|
||||
env->DeleteLocalRef(element);
|
||||
env->SetObjectArrayElement(array, 0, nullptr);
|
||||
EXPECT_EQ(env->GetObjectArrayElement(array, 0), nullptr);
|
||||
jstring second_reuse = env->NewStringUTF("second slot reused");
|
||||
ASSERT_NE(second_reuse, nullptr);
|
||||
EXPECT_EQ(second_reuse, second);
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(first_reuse);
|
||||
env->DeleteLocalRef(second_reuse);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayDeletionRecursivelyReleasesNestedElements) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jclass object_class = env->FindClass("java/lang/Object");
|
||||
jstring initial = env->NewStringUTF("nested string");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray inner = env->NewObjectArray(1, string_class, initial);
|
||||
ASSERT_NE(inner, nullptr);
|
||||
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
|
||||
ASSERT_NE(outer, nullptr);
|
||||
env->DeleteLocalRef(initial);
|
||||
env->DeleteLocalRef(inner);
|
||||
|
||||
auto retained_inner =
|
||||
static_cast<jobjectArray>(env->GetObjectArrayElement(outer, 0));
|
||||
ASSERT_NE(retained_inner, nullptr);
|
||||
auto element =
|
||||
static_cast<jstring>(env->GetObjectArrayElement(retained_inner, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "nested string");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(retained_inner);
|
||||
env->DeleteLocalRef(outer);
|
||||
jstring recycled = env->NewStringUTF("recursive release completed");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(string_class);
|
||||
env->DeleteLocalRef(object_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayNullAndEmptyArraysDoNotRetainInitializer) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jstring initial = env->NewStringUTF("not stored in an empty array");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray empty = env->NewObjectArray(0, string_class, initial);
|
||||
jobjectArray nulls = env->NewObjectArray(3, string_class, nullptr);
|
||||
ASSERT_NE(empty, nullptr);
|
||||
ASSERT_NE(nulls, nullptr);
|
||||
EXPECT_EQ(env->GetArrayLength(empty), 0);
|
||||
EXPECT_EQ(env->GetArrayLength(nulls), 3);
|
||||
for (jsize index = 0; index < 3; ++index) {
|
||||
EXPECT_EQ(env->GetObjectArrayElement(nulls, index), nullptr);
|
||||
env->SetObjectArrayElement(nulls, index, nullptr);
|
||||
}
|
||||
env->DeleteLocalRef(initial);
|
||||
jstring recycled = env->NewStringUTF("empty initializer was released");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(empty);
|
||||
env->DeleteLocalRef(nulls);
|
||||
EXPECT_EQ(ReadJavaString(env, recycled), "empty initializer was released");
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArraySelfReferenceCanBeBrokenWithValidExternalReference) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass object_class = env->FindClass("java/lang/Object");
|
||||
jobjectArray array = env->NewObjectArray(2, object_class, nullptr);
|
||||
ASSERT_NE(array, nullptr);
|
||||
jstring initial = env->NewStringUTF("owned beside self reference");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
env->SetObjectArrayElement(array, 0, array);
|
||||
env->SetObjectArrayElement(array, 1, initial);
|
||||
auto external = static_cast<jobjectArray>(env->NewGlobalRef(array));
|
||||
ASSERT_NE(external, nullptr);
|
||||
env->DeleteLocalRef(array);
|
||||
env->DeleteLocalRef(initial);
|
||||
|
||||
jobject self = env->GetObjectArrayElement(external, 0);
|
||||
ASSERT_NE(self, nullptr);
|
||||
EXPECT_EQ(env->IsSameObject(self, external), JNI_TRUE);
|
||||
env->DeleteLocalRef(self);
|
||||
env->SetObjectArrayElement(external, 0, nullptr);
|
||||
auto element = static_cast<jstring>(env->GetObjectArrayElement(external, 1));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "owned beside self reference");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteGlobalRef(external);
|
||||
jstring recycled = env->NewStringUTF("broken cycle released its element");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(object_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArraySurvivesTemporaryVmTeardownAndEnvironmentReattachment) {
|
||||
JNIEnv *env = vm_->GetJNIEnv();
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jstring initial = env->NewStringUTF("surviving VM array");
|
||||
ASSERT_NE(initial, nullptr);
|
||||
jobjectArray array = env->NewObjectArray(1, string_class, initial);
|
||||
ASSERT_NE(array, nullptr);
|
||||
env->DeleteLocalRef(initial);
|
||||
{
|
||||
VM temporary;
|
||||
ASSERT_NE(temporary.GetJNIEnv(), nullptr);
|
||||
}
|
||||
// Temporary VM teardown invalidates the thread's environment; reacquire it
|
||||
// before invoking JNI on references owned by the surviving VM.
|
||||
env = vm_->GetJNIEnv();
|
||||
ASSERT_NE(env, nullptr);
|
||||
auto element = static_cast<jstring>(env->GetObjectArrayElement(array, 0));
|
||||
ASSERT_NE(element, nullptr);
|
||||
EXPECT_EQ(ReadJavaString(env, element), "surviving VM array");
|
||||
env->DeleteLocalRef(element);
|
||||
env->DeleteLocalRef(array);
|
||||
jstring recycled = env->NewStringUTF("surviving array released");
|
||||
ASSERT_NE(recycled, nullptr);
|
||||
EXPECT_EQ(recycled, initial);
|
||||
env->DeleteLocalRef(recycled);
|
||||
env->DeleteLocalRef(string_class);
|
||||
}
|
||||
|
||||
TEST_F(JniVmTest, ObjectArrayLiveAtProcessExitDoesNotReenterDestroyedTables) {
|
||||
ASSERT_EXIT(
|
||||
{
|
||||
VM temporary;
|
||||
JNIEnv *env = temporary.GetJNIEnv();
|
||||
if (env == nullptr) std::_Exit(10);
|
||||
jclass string_class = env->FindClass("java/lang/String");
|
||||
jclass object_class = env->FindClass("java/lang/Object");
|
||||
jstring initial = env->NewStringUTF("live at shutdown");
|
||||
if (initial == nullptr) std::_Exit(11);
|
||||
jobjectArray inner = env->NewObjectArray(3, string_class, initial);
|
||||
if (inner == nullptr) std::_Exit(12);
|
||||
jobjectArray outer = env->NewObjectArray(1, object_class, inner);
|
||||
if (outer == nullptr) std::_Exit(13);
|
||||
env->DeleteLocalRef(initial);
|
||||
env->DeleteLocalRef(inner);
|
||||
// Run global destructors with outer/inner/string still alive. Release
|
||||
// of elements belongs to explicit reference release, not ~PseudoArray.
|
||||
std::exit(0);
|
||||
},
|
||||
::testing::ExitedWithCode(0), "");
|
||||
}
|
||||
|
||||
constexpr int kMoreThanHandleCapacity = 150000;
|
||||
|
||||
TEST_F(JniVmTest, RepeatedFindClassDoesNotExhaustHandles) {
|
||||
@@ -2225,7 +2519,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
|
||||
|
||||
CallStaticVoidMethodVForTest(env, cls, show, static_cast<jlong>(42), JNI_TRUE,
|
||||
text, info);
|
||||
EXPECT_EQ(probe->show_calls, 1);
|
||||
EXPECT_EQ(probe->show_calls, 2);
|
||||
|
||||
jvalue show_args[4] = {};
|
||||
show_args[0].j = 43;
|
||||
@@ -2233,7 +2527,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
|
||||
show_args[2].l = text;
|
||||
show_args[3].l = info;
|
||||
env->CallStaticVoidMethodA(cls, show, show_args);
|
||||
EXPECT_EQ(probe->show_calls, 2);
|
||||
EXPECT_EQ(probe->show_calls, 3);
|
||||
EXPECT_EQ(probe->request.text_box, 43);
|
||||
|
||||
jmethodID wrong_signature =
|
||||
@@ -2243,7 +2537,7 @@ TEST_F(JniVmTest, RobloxTextInputCapturesExactStaticDirectVAndACalls) {
|
||||
jclass wrong_cls = env->FindClass("example/NativeGLJavaInterface");
|
||||
env->CallStaticVoidMethod(wrong_cls, show, static_cast<jlong>(44), JNI_FALSE,
|
||||
text, info);
|
||||
EXPECT_EQ(probe->show_calls, 2);
|
||||
EXPECT_EQ(probe->show_calls, 3);
|
||||
|
||||
jstring replacement = env->NewStringUTF("engine replacement");
|
||||
env->CallStaticVoidMethod(cls, replace, replacement);
|
||||
|
||||
+977
-4
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,310 @@
|
||||
#!/usr/bin/env python3
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# Licensed under the Apache License, Version 2.0.
|
||||
|
||||
"""Exercise the full legacy importer with synthetic APKs and failing renames.
|
||||
|
||||
APK/ELF inspection tools are fixtures; hashing, extraction, locking, metadata,
|
||||
and filesystem operations use the real tools. No installed payload is touched.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPONENTS = ('libroblox.so', 'sober_apk', 'assets', 'roblox_payload.json')
|
||||
OLD_FILES = {
|
||||
'libroblox.so': b'OLD-LIBRARY',
|
||||
'sober_apk/base.apk': b'OLD-APK',
|
||||
'sober_apk/split_config.x86_64.apk': b'OLD-SPLIT',
|
||||
'assets/content/data.bin': b'OLD-ASSET',
|
||||
'roblox_payload.json': b'{"old_metadata":true}\n',
|
||||
}
|
||||
|
||||
TOOL = r'''
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
tool = Path(sys.argv[0]).name
|
||||
args = sys.argv[1:]
|
||||
if tool == 'aapt':
|
||||
old = Path(args[-1]).read_bytes().startswith(b'OLD-')
|
||||
code, name = ('100', '1.0') if old else ('200', '2.0')
|
||||
split = " split='config.x86_64'" if 'split_' in args[-1] else ''
|
||||
print(f"package: name='com.roblox.client' versionCode='{code}' versionName='{name}'{split}")
|
||||
elif tool == 'apksigner':
|
||||
if '--print-certs' in args:
|
||||
print('Signer #1 certificate SHA-256 digest: ' + 'a' * 64)
|
||||
elif tool == 'file':
|
||||
print(args[-1] + ': ELF 64-bit for Android')
|
||||
elif tool == 'readelf':
|
||||
if '-h' in args:
|
||||
print(' Machine: Advanced Micro Devices X86-64')
|
||||
elif '-n' in args:
|
||||
old = Path(args[-1]).read_bytes().startswith(b'OLD-')
|
||||
print('Build ID: ' + ('1' if old else '2') * 40)
|
||||
else:
|
||||
print('JNI_OnLoad')
|
||||
for name in ('nativeGameGlobalInit', 'nativeUpdateAdapterInit',
|
||||
'nativeAppBridgeV2InitWithParams', 'nativeAppBridgeStartLuaAppDM',
|
||||
'nativeAppBridgeV2StartAppWithParams',
|
||||
'nativeAppBridgeV2UpdateSurfaceAppWithPlatformParams'):
|
||||
print('Java_com_roblox_engine_jni_NativeGLInterface_' + name)
|
||||
elif tool == 'mv':
|
||||
source, destination = map(Path, args[-2:])
|
||||
phase = 'other'
|
||||
component = source.name
|
||||
if destination.parent.name == 'rollback':
|
||||
phase = 'backup'
|
||||
elif source.parent.name == 'new':
|
||||
phase = 'activate'
|
||||
elif destination.name.startswith('failed-'):
|
||||
phase = 'discard'
|
||||
component = 'roblox_payload.json' if destination.name == 'failed-metadata.json' else component
|
||||
elif source.parent.name == 'rollback':
|
||||
phase = 'restore'
|
||||
elif source.name == 'rollback' and destination.parent.name == 'versions':
|
||||
phase = 'archive'
|
||||
with open(os.environ['NIGHTCAP_TEST_MOVES'], 'a') as log:
|
||||
log.write(json.dumps([phase, component]) + '\n')
|
||||
failures = json.loads(os.environ['NIGHTCAP_TEST_FAILURES'])
|
||||
if phase == 'backup' and ['partial-backup', component] in failures:
|
||||
if source.is_dir():
|
||||
destination.mkdir()
|
||||
(destination / 'partial').write_bytes(b'INCOMPLETE-COPY')
|
||||
else:
|
||||
destination.write_bytes(b'INCOMPLETE-COPY')
|
||||
print(f'injected failure: partial-backup/{component}', file=sys.stderr)
|
||||
sys.exit(74)
|
||||
if [phase, component] in failures:
|
||||
print(f'injected failure: {phase}/{component}', file=sys.stderr)
|
||||
sys.exit(74)
|
||||
sys.exit(subprocess.call([os.environ['NIGHTCAP_TEST_REAL_MV'], *args]))
|
||||
else:
|
||||
raise AssertionError(tool)
|
||||
'''
|
||||
|
||||
|
||||
class PayloadImportRollbackTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix='nightcap-import-test-')
|
||||
self.addCleanup(self.temporary.cleanup)
|
||||
self.root = Path(self.temporary.name)
|
||||
self.project = self.root / 'project with spaces'
|
||||
self.bin = self.root / 'bin'
|
||||
self.moves = self.root / 'moves.jsonl'
|
||||
for tool in ('bash', 'jq', 'unzip', 'flock', 'mv'):
|
||||
self.assertIsNotNone(shutil.which(tool), f'required test tool missing: {tool}')
|
||||
self.real_mv = shutil.which('mv')
|
||||
(self.project / 'scripts').mkdir(parents=True)
|
||||
(self.project / 'config').mkdir()
|
||||
self.bin.mkdir()
|
||||
for name in ('update_roblox_payload.sh', 'payload_integrity.sh'):
|
||||
shutil.copy2(ROOT / 'scripts' / name, self.project / 'scripts' / name)
|
||||
for name in ('aapt', 'apksigner', 'readelf', 'file', 'mv'):
|
||||
path = self.bin / name
|
||||
path.write_text(f'#!{sys.executable}\n' + TOOL)
|
||||
path.chmod(0o755)
|
||||
(self.project / 'config/roblox_signing_certificates.json').write_text(
|
||||
json.dumps({'schema_version': 1, 'package': 'com.roblox.client',
|
||||
'trusted_sha256': ['a' * 64]})
|
||||
)
|
||||
self.base = self.root / 'base.apk'
|
||||
self.split = self.root / 'split_config.x86_64.apk'
|
||||
with zipfile.ZipFile(self.base, 'w') as archive:
|
||||
archive.writestr('assets/content/data.bin', b'NEW-ASSET')
|
||||
with zipfile.ZipFile(self.split, 'w') as archive:
|
||||
archive.writestr('lib/x86_64/libroblox.so', b'NEW-LIBRARY')
|
||||
|
||||
def active_path(self, component):
|
||||
directory = 'config' if component == 'roblox_payload.json' else 'rbx_bin'
|
||||
return self.project / directory / component
|
||||
|
||||
def create_previous(self, components=COMPONENTS):
|
||||
shutil.rmtree(self.project / 'rbx_bin', ignore_errors=True)
|
||||
self.active_path('roblox_payload.json').unlink(missing_ok=True)
|
||||
for relative, content in OLD_FILES.items():
|
||||
if relative.split('/')[0] not in components:
|
||||
continue
|
||||
path = self.active_path(relative.split('/')[0])
|
||||
if '/' in relative:
|
||||
path = path / relative.split('/', 1)[1]
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
|
||||
def contents(self, path):
|
||||
if path.is_symlink():
|
||||
return ('symlink', os.readlink(path))
|
||||
if not path.exists():
|
||||
return None
|
||||
if path.is_file():
|
||||
return path.read_bytes()
|
||||
return {str(p.relative_to(path)): p.read_bytes()
|
||||
for p in path.rglob('*') if p.is_file()}
|
||||
|
||||
def snapshot(self):
|
||||
return {name: self.contents(self.active_path(name)) for name in COMPONENTS}
|
||||
|
||||
def run_import(self, failures=()):
|
||||
self.moves.unlink(missing_ok=True)
|
||||
env = {key: value for key, value in os.environ.items()
|
||||
if not key.startswith(('MOCKTAIL_', 'NIGHTCAP_TEST_'))}
|
||||
env.update(PATH=str(self.bin) + os.pathsep + env['PATH'],
|
||||
NIGHTCAP_TEST_REAL_MV=self.real_mv,
|
||||
NIGHTCAP_TEST_FAILURES=json.dumps(failures),
|
||||
NIGHTCAP_TEST_MOVES=str(self.moves))
|
||||
return subprocess.run(
|
||||
['bash', str(self.project / 'scripts/update_roblox_payload.sh'),
|
||||
'--base', str(self.base), '--x86-64', str(self.split)],
|
||||
env=env, capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
|
||||
def backups(self):
|
||||
return list((self.project / 'rbx_bin').glob('.payload-update.*'))
|
||||
|
||||
def assert_failed_and_restored(self, result, before, phase, component):
|
||||
self.assertEqual(result.returncode, 74, result.stderr)
|
||||
self.assertIn(f'injected failure: {phase}/{component}', result.stderr)
|
||||
self.assertEqual(self.snapshot(), before)
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
def test_backup_failures_preserve_every_previous_component(self):
|
||||
for component in COMPONENTS:
|
||||
with self.subTest(component=component):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['backup', component]])
|
||||
self.assert_failed_and_restored(result, before, 'backup', component)
|
||||
|
||||
def test_activation_failures_restore_previous_components(self):
|
||||
for component in COMPONENTS:
|
||||
with self.subTest(component=component):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['activate', component]])
|
||||
self.assert_failed_and_restored(result, before, 'activate', component)
|
||||
|
||||
def test_failed_first_install_removes_activated_components(self):
|
||||
for component in COMPONENTS:
|
||||
with self.subTest(component=component):
|
||||
self.create_previous(())
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['activate', component]])
|
||||
self.assert_failed_and_restored(result, before, 'activate', component)
|
||||
|
||||
def test_sparse_previous_install_is_restored(self):
|
||||
self.create_previous(('assets', 'roblox_payload.json'))
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['activate', 'roblox_payload.json']])
|
||||
self.assert_failed_and_restored(result, before, 'activate', 'roblox_payload.json')
|
||||
|
||||
def test_partial_backup_copy_does_not_replace_untouched_original(self):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['partial-backup', 'sober_apk']])
|
||||
self.assert_failed_and_restored(result, before, 'partial-backup', 'sober_apk')
|
||||
|
||||
def test_restore_failure_retains_backup_and_restores_other_components(self):
|
||||
for component in COMPONENTS:
|
||||
with self.subTest(component=component):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['archive', 'rollback'], ['restore', component]])
|
||||
self.assertEqual(result.returncode, 74, result.stderr)
|
||||
self.assertIn(f'injected failure: restore/{component}', result.stderr)
|
||||
workspaces = self.backups()
|
||||
self.assertEqual(len(workspaces), 1)
|
||||
workspace = workspaces[0]
|
||||
self.assertIn(str(workspace), result.stderr)
|
||||
self.assertEqual(self.contents(workspace / 'rollback' / component), before[component])
|
||||
for other in COMPONENTS:
|
||||
if other != component:
|
||||
self.assertEqual(self.contents(self.active_path(other)), before[other])
|
||||
# A failed recovery is intentionally retained by the importer.
|
||||
shutil.rmtree(workspace)
|
||||
|
||||
def test_discard_failure_does_not_move_backup_into_active_directory(self):
|
||||
for component in COMPONENTS:
|
||||
with self.subTest(component=component):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import([['archive', 'rollback'], ['discard', component]])
|
||||
self.assertEqual(result.returncode, 74, result.stderr)
|
||||
self.assertIn(f'injected failure: discard/{component}', result.stderr)
|
||||
workspaces = self.backups()
|
||||
self.assertEqual(len(workspaces), 1)
|
||||
workspace = workspaces[0]
|
||||
self.assertIn(str(workspace), result.stderr)
|
||||
self.assertEqual(self.contents(workspace / 'rollback' / component), before[component])
|
||||
moves = [json.loads(line) for line in self.moves.read_text().splitlines()]
|
||||
self.assertNotIn(['restore', component], moves)
|
||||
for other in COMPONENTS:
|
||||
if other != component:
|
||||
self.assertEqual(self.contents(self.active_path(other)), before[other])
|
||||
shutil.rmtree(workspace)
|
||||
|
||||
def test_successful_import_keeps_previous_archive_and_consistent_metadata(self):
|
||||
self.create_previous()
|
||||
before = self.snapshot()
|
||||
result = self.run_import()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(self.active_path('libroblox.so').read_bytes(), b'NEW-LIBRARY')
|
||||
self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET')
|
||||
self.assertEqual((self.active_path('sober_apk') / 'base.apk').read_bytes(), self.base.read_bytes())
|
||||
metadata = json.loads(self.active_path('roblox_payload.json').read_text())
|
||||
self.assertEqual(metadata['version_code'], 200)
|
||||
self.assertEqual(metadata['elf_build_id'], '2' * 40)
|
||||
self.assertEqual(metadata['compatibility_status'], 'unverified')
|
||||
self.assertEqual(metadata['sha256']['libroblox'], hashlib.sha256(b'NEW-LIBRARY').hexdigest())
|
||||
self.assertEqual(metadata['sha256']['base_apk'], hashlib.sha256(self.base.read_bytes()).hexdigest())
|
||||
self.assertEqual(metadata['sha256']['x86_64_split_apk'], hashlib.sha256(self.split.read_bytes()).hexdigest())
|
||||
archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40)
|
||||
for component in COMPONENTS:
|
||||
self.assertEqual(self.contents(archive / component), before[component])
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
def test_successful_first_install_has_no_previous_archive(self):
|
||||
result = self.run_import()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS))
|
||||
self.assertEqual(list((self.project / 'rbx_bin/versions').iterdir()), [])
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
def test_successful_sparse_install_archives_unversioned_components(self):
|
||||
self.create_previous(('assets', 'roblox_payload.json'))
|
||||
before = self.snapshot()
|
||||
result = self.run_import()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
archives = list((self.project / 'rbx_bin/versions').glob('unknown-*'))
|
||||
self.assertEqual(len(archives), 1)
|
||||
for component in COMPONENTS:
|
||||
self.assertEqual(self.contents(archives[0] / component), before[component])
|
||||
self.assertTrue(all(self.active_path(name).exists() for name in COMPONENTS))
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
def test_successful_import_archives_dangling_asset_symlink(self):
|
||||
self.create_previous()
|
||||
shutil.rmtree(self.active_path('assets'))
|
||||
self.active_path('assets').symlink_to('missing-assets')
|
||||
result = self.run_import()
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
archive = self.project / 'rbx_bin/versions' / ('1.0-' + '1' * 40)
|
||||
self.assertEqual(self.contents(archive / 'assets'), ('symlink', 'missing-assets'))
|
||||
self.assertEqual((self.active_path('assets') / 'content/data.bin').read_bytes(), b'NEW-ASSET')
|
||||
self.assertEqual(self.backups(), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -340,6 +340,66 @@ TEST(PlatformCacheMigrationTest, MissingRobloxThemeUsesCallerDefault) {
|
||||
EXPECT_FALSE(std::filesystem::exists(fixture.app_storage));
|
||||
}
|
||||
|
||||
TEST(PlatformCacheMigrationTest, UnknownAccountThemeUsesAuthenticatedTheme) {
|
||||
TemporaryDirectory temporary;
|
||||
FixturePaths fixture = PathsFor(temporary);
|
||||
const nlohmann::json invalid_themes[] = {
|
||||
"system", "", nullptr, false, 42, nlohmann::json::object()};
|
||||
for (const auto& theme : invalid_themes) {
|
||||
SCOPED_TRACE(theme.dump());
|
||||
const nlohmann::json device_themes = {{"42", theme}, {"99", "dark"}};
|
||||
ASSERT_TRUE(WriteJson(fixture.app_storage,
|
||||
{{"DeviceLevelTheme", device_themes.dump()},
|
||||
{"AuthenticatedTheme", "light"},
|
||||
{"UnrelatedPreference", "keep"}}));
|
||||
const std::string original = ReadFile(fixture.app_storage);
|
||||
|
||||
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_EQ(result.dark_theme, std::optional<bool>(false));
|
||||
EXPECT_EQ(ReadFile(fixture.app_storage), original);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PlatformCacheMigrationTest, MalformedDeviceThemesUseAuthenticatedTheme) {
|
||||
TemporaryDirectory temporary;
|
||||
FixturePaths fixture = PathsFor(temporary);
|
||||
const nlohmann::json invalid_caches[] = {
|
||||
"{broken", "[]", "null", 42, nullptr, nlohmann::json::object()};
|
||||
for (const auto& cache : invalid_caches) {
|
||||
SCOPED_TRACE(cache.dump());
|
||||
ASSERT_TRUE(WriteJson(fixture.app_storage,
|
||||
{{"DeviceLevelTheme", cache},
|
||||
{"AuthenticatedTheme", "dark"}}));
|
||||
const std::string original = ReadFile(fixture.app_storage);
|
||||
|
||||
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_EQ(result.dark_theme, std::optional<bool>(true));
|
||||
EXPECT_EQ(ReadFile(fixture.app_storage), original);
|
||||
}
|
||||
}
|
||||
|
||||
TEST(PlatformCacheMigrationTest, UnknownThemesUseCallerDefault) {
|
||||
TemporaryDirectory temporary;
|
||||
FixturePaths fixture = PathsFor(temporary);
|
||||
const nlohmann::json device_themes = {{"42", "system"}};
|
||||
const nlohmann::json invalid_themes[] = {
|
||||
"future-theme", "", nullptr, true, 42, nlohmann::json::array()};
|
||||
for (const auto& theme : invalid_themes) {
|
||||
SCOPED_TRACE(theme.dump());
|
||||
ASSERT_TRUE(WriteJson(fixture.app_storage,
|
||||
{{"DeviceLevelTheme", device_themes.dump()},
|
||||
{"AuthenticatedTheme", theme}}));
|
||||
const std::string original = ReadFile(fixture.app_storage);
|
||||
|
||||
const auto result = ReadRobloxThemeCache(fixture.app_storage, 42);
|
||||
ASSERT_TRUE(result) << result.error;
|
||||
EXPECT_FALSE(result.dark_theme.has_value());
|
||||
EXPECT_EQ(ReadFile(fixture.app_storage), original);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace runtime
|
||||
} // namespace mocktail
|
||||
@@ -76,6 +76,8 @@ TEST(AngleProbeTest, RequiresExplicitPinnedLibraryPaths) {
|
||||
auto capability = graphics::ProbeAngleVulkan({});
|
||||
EXPECT_EQ(capability.state, CapabilityState::kUnavailable);
|
||||
EXPECT_NE(capability.detail.find("required"), std::string::npos);
|
||||
EXPECT_EQ(graphics::InspectAngleLibraries({}).state,
|
||||
CapabilityState::kUnavailable);
|
||||
}
|
||||
|
||||
TEST(AngleProbeTest, ValidatesConfiguredAngleVulkanDistribution) {
|
||||
@@ -88,6 +90,8 @@ TEST(AngleProbeTest, ValidatesConfiguredAngleVulkanDistribution) {
|
||||
graphics::AngleProbeOptions options;
|
||||
options.egl_library_path = egl;
|
||||
options.gles_library_path = gles;
|
||||
const auto inspected = graphics::InspectAngleLibraries(options);
|
||||
ASSERT_EQ(inspected.state, CapabilityState::kLoadable) << inspected.detail;
|
||||
auto capability = graphics::ProbeAngleVulkan(options);
|
||||
EXPECT_EQ(capability.state, CapabilityState::kReady) << capability.detail;
|
||||
EXPECT_EQ(capability.acceleration, HardwareAcceleration::kHardware);
|
||||
|
||||
@@ -308,7 +308,9 @@ RobloxAccountProtocolSymbols AccountSymbols() {
|
||||
SetAccountHandler, ClearAccountHandler};
|
||||
}
|
||||
void ClearNativeRequestHandler(JNIEnv*, jobject, jstring, jstring) {}
|
||||
|
||||
void PublishRaw(JNIEnv*, jobject, jstring, jstring) {}
|
||||
|
||||
void BroadcastDataModelFocus(JNIEnv*, jclass, jstring, jstring, jstring) {}
|
||||
|
||||
jstring GetLaunchId(JNIEnv* env, jclass) {
|
||||
@@ -399,14 +401,17 @@ jobject Subscribe(JNIEnv* env, jobject, jstring id, jobject callback,
|
||||
}
|
||||
|
||||
void Disconnect(JNIEnv*, jobject, jlong handle) {
|
||||
if (handle == 91) ++g_probe->disconnects;
|
||||
if (handle == 91)
|
||||
++g_probe->disconnects;
|
||||
}
|
||||
|
||||
void Foreground(JNIEnv* env, jclass, jboolean enabled, jstring reason) {
|
||||
if (reason == nullptr) return;
|
||||
if (reason == nullptr)
|
||||
return;
|
||||
const char* chars = env->GetStringUTFChars(reason, nullptr);
|
||||
const std::string value = chars != nullptr ? chars : "";
|
||||
if (chars != nullptr) env->ReleaseStringUTFChars(reason, chars);
|
||||
if (chars != nullptr)
|
||||
env->ReleaseStringUTFChars(reason, chars);
|
||||
if (value == "ASMA.stop" || value == "ASMA.start") {
|
||||
g_probe->app_surface_lifecycle.push_back(
|
||||
std::string(enabled == JNI_TRUE ? "background:" : "foreground:") +
|
||||
@@ -437,7 +442,10 @@ jint Start(JNIEnv* env, jclass, jobject params) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Update(JNIEnv*, jclass, jobject, jobject, jobject) { ++g_probe->updates; }
|
||||
void Update(JNIEnv*, jclass, jobject, jobject, jobject) {
|
||||
++g_probe->updates;
|
||||
}
|
||||
|
||||
void UpdateApp(JNIEnv* env, jclass, jobject surface, jobject platform_params) {
|
||||
++g_probe->app_updates;
|
||||
jclass surface_class = env->GetObjectClass(surface);
|
||||
@@ -449,15 +457,25 @@ void UpdateApp(JNIEnv* env, jclass, jobject surface, jobject platform_params) {
|
||||
env->DeleteLocalRef(surface_class);
|
||||
env->DeleteLocalRef(params_class);
|
||||
}
|
||||
|
||||
void PauseGame(JNIEnv*, jclass) {}
|
||||
|
||||
void CallMessagesFromMainThread(JNIEnv*, jclass) {}
|
||||
|
||||
void ResumeGame(JNIEnv*, jclass, jobject, jobject, jobject) {}
|
||||
void Leave(JNIEnv*, jclass) { ++g_probe->leaves; }
|
||||
|
||||
void Leave(JNIEnv*, jclass) {
|
||||
++g_probe->leaves;
|
||||
}
|
||||
|
||||
void PauseApp(JNIEnv*, jclass) {
|
||||
++g_probe->app_pauses;
|
||||
g_probe->app_surface_lifecycle.emplace_back("pause-app");
|
||||
}
|
||||
void DestroyApp(JNIEnv*, jclass) { ++g_probe->destroys; }
|
||||
|
||||
void DestroyApp(JNIEnv*, jclass) {
|
||||
++g_probe->destroys;
|
||||
}
|
||||
|
||||
void StartApp(JNIEnv* env, jclass, jobject params) {
|
||||
++g_probe->app_starts;
|
||||
@@ -936,8 +954,9 @@ TEST(RobloxExperienceCompositionTest,
|
||||
|
||||
ASSERT_TRUE(composition.InitializePlatformProtocols().ok());
|
||||
EXPECT_FALSE(composition.subscribed());
|
||||
EXPECT_EQ(probe.permission_subscriptions, 5);
|
||||
EXPECT_EQ(probe.permission_handlers.size(), 5u);
|
||||
EXPECT_EQ(probe.permission_subscriptions, 6);
|
||||
EXPECT_EQ(probe.permission_handlers.size(), 6u);
|
||||
EXPECT_EQ(probe.permission_handlers.count("getCallState"), 1u);
|
||||
EXPECT_EQ(QueryMicrophone(&probe).at("status"), "DENIED");
|
||||
EXPECT_EQ(probe.web_view_initializations, 1);
|
||||
EXPECT_EQ(probe.browser_bindings, 4);
|
||||
@@ -946,8 +965,8 @@ TEST(RobloxExperienceCompositionTest,
|
||||
EXPECT_EQ(probe.browser_releases, 0);
|
||||
EXPECT_EQ(probe.browser_callbacks_cleared, 0);
|
||||
EXPECT_TRUE(composition.Shutdown().ok());
|
||||
EXPECT_EQ(probe.permission_disconnects, 5);
|
||||
EXPECT_EQ(probe.permission_handlers_cleared, 5);
|
||||
EXPECT_EQ(probe.permission_disconnects, 6);
|
||||
EXPECT_EQ(probe.permission_handlers_cleared, 6);
|
||||
EXPECT_EQ(probe.disconnects, 3);
|
||||
EXPECT_EQ(probe.browser_disconnects, 4);
|
||||
EXPECT_EQ(probe.browser_releases, 4);
|
||||
@@ -1110,7 +1129,8 @@ class RobloxExperienceCompositionWebSurfaceTest : public ::testing::Test {
|
||||
explicit Helper(const char* before_ready = "") {
|
||||
char pattern[] = "/tmp/mocktail_surface_close_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
if (created == nullptr) return;
|
||||
if (created == nullptr)
|
||||
return;
|
||||
directory = created;
|
||||
path = directory / "helper";
|
||||
std::ofstream output(path);
|
||||
@@ -1127,13 +1147,18 @@ while True:
|
||||
break
|
||||
)PY";
|
||||
output.close();
|
||||
if (!output.good() || chmod(path.c_str(), 0700) != 0) path.clear();
|
||||
if (!output.good() || chmod(path.c_str(), 0700) != 0)
|
||||
path.clear();
|
||||
}
|
||||
|
||||
~Helper() {
|
||||
if (process != nullptr) (void)process->RequestClose();
|
||||
if (process != nullptr)
|
||||
(void)process->RequestClose();
|
||||
std::error_code error;
|
||||
if (!directory.empty()) std::filesystem::remove_all(directory, error);
|
||||
if (!directory.empty())
|
||||
std::filesystem::remove_all(directory, error);
|
||||
}
|
||||
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path path;
|
||||
std::shared_ptr<WebViewHelperProcess> process;
|
||||
|
||||
@@ -698,6 +698,47 @@ TEST_F(RobloxInputRouterTest, ReportsHostTextFocusCompletion) {
|
||||
EXPECT_EQ(router_.Snapshot().text_focus_generation, 4U);
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, MouseClicksKeepTextFocusUntilEngineReleasesIt) {
|
||||
RobloxTextFocusSession session{42, 1, "draft", false, false};
|
||||
session.area_x = 40;
|
||||
session.area_y = 300;
|
||||
session.area_width = 400;
|
||||
session.area_height = 30;
|
||||
ASSERT_TRUE(router_.BeginTextFocusSession(session).ok());
|
||||
|
||||
for (const auto button : {SDL_BUTTON_LEFT, SDL_BUTTON_MIDDLE,
|
||||
SDL_BUTTON_RIGHT}) {
|
||||
SCOPED_TRACE(button);
|
||||
for (const bool pressed : {true, false}) {
|
||||
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
|
||||
pressed, static_cast<uint8_t>(button), 1, 100.0F, 310.0F}))
|
||||
.dispatched());
|
||||
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
|
||||
}
|
||||
}
|
||||
EXPECT_TRUE(probe_.text_operations.empty());
|
||||
EXPECT_EQ(probe_.mouse_buttons.size(), 6U);
|
||||
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
|
||||
.dispatched());
|
||||
EXPECT_EQ(probe_.text_operations,
|
||||
(std::vector<std::string>{"sync", "pass"}));
|
||||
|
||||
ASSERT_TRUE(router_.EndTextFocusSession(42, 1, false).ok());
|
||||
EXPECT_EQ(router_.Snapshot().text_focus_generation, 0U);
|
||||
EXPECT_FALSE(router_.HandleEvent(Event(platform::TextInputEvent{"late"}))
|
||||
.dispatched());
|
||||
}
|
||||
|
||||
TEST_F(RobloxInputRouterTest, MouseClickRespectsManualTextFocusRelease) {
|
||||
ASSERT_TRUE(router_.BeginTextFocusSession({42, 1, "draft", true, false}).ok());
|
||||
ASSERT_TRUE(router_.HandleEvent(Event(platform::MouseButtonEvent{
|
||||
true, SDL_BUTTON_LEFT, 1, 900.0F, 600.0F})).dispatched());
|
||||
EXPECT_TRUE(probe_.text_operations.empty());
|
||||
EXPECT_EQ(router_.Snapshot().text_focus_generation, 1U);
|
||||
EXPECT_TRUE(router_.HandleEvent(Event(platform::TextInputEvent{" next"}))
|
||||
.dispatched());
|
||||
}
|
||||
|
||||
TEST(RobloxInputRouterClipboardTest,
|
||||
CtrlVPastesWithoutForwardingTheLetterKeyToRoblox) {
|
||||
Probe probe;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include <vector>
|
||||
|
||||
#include "jnivm/jnivm.h"
|
||||
#include "runtime/roblox_call_protocol_bridge.h"
|
||||
|
||||
namespace mocktail {
|
||||
namespace runtime {
|
||||
@@ -23,15 +24,18 @@ using Json = nlohmann::json;
|
||||
constexpr char kRequest[] = R"({"permissions":["MICROPHONE_ACCESS"]})";
|
||||
|
||||
std::string Copy(JNIEnv* env, jstring value) {
|
||||
if (!value) return {};
|
||||
if (!value)
|
||||
return {};
|
||||
const char* chars = env->GetStringUTFChars(value, nullptr);
|
||||
const std::string copy = chars ? chars : "";
|
||||
if (chars) env->ReleaseStringUTFChars(value, chars);
|
||||
if (chars)
|
||||
env->ReleaseStringUTFChars(value, chars);
|
||||
return copy;
|
||||
}
|
||||
|
||||
struct Probe {
|
||||
jnivm::VM* vm = nullptr;
|
||||
std::string expected_protocol = "PermissionsProtocol";
|
||||
std::map<std::string, jobject> legacy;
|
||||
std::map<std::string, jobject> handlers;
|
||||
int callbacks_created = 0;
|
||||
@@ -50,11 +54,13 @@ struct Probe {
|
||||
bool block_publish = false;
|
||||
bool publish_entered = false;
|
||||
bool release_publish = false;
|
||||
|
||||
struct Published {
|
||||
std::string method;
|
||||
Json body;
|
||||
jint code;
|
||||
};
|
||||
|
||||
std::vector<Published> published;
|
||||
std::vector<std::pair<std::string, Json>> resolved;
|
||||
};
|
||||
@@ -68,7 +74,8 @@ void Prepare(void* context) {
|
||||
jobject CreateRaw(void* context, std::shared_ptr<void> target,
|
||||
void (*run)(void*, JNIEnv*, jstring)) {
|
||||
auto* probe = static_cast<Probe*>(context);
|
||||
if (++probe->callbacks_created == probe->fail_callback_at) return nullptr;
|
||||
if (++probe->callbacks_created == probe->fail_callback_at)
|
||||
return nullptr;
|
||||
return probe->vm->CreateMessageBusRawCallback(
|
||||
std::move(target), jnivm::MessageBusRawCallbacks{run});
|
||||
}
|
||||
@@ -113,19 +120,22 @@ void InvokeAsync(JNIEnv* env, jobject handler, const char* payload,
|
||||
args[0].l = message;
|
||||
args[1].l = id;
|
||||
env->CallVoidMethodA(handler, run, args);
|
||||
if (message) env->DeleteLocalRef(message);
|
||||
if (message)
|
||||
env->DeleteLocalRef(message);
|
||||
env->DeleteLocalRef(id);
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
|
||||
jobject Subscribe(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
jobject callback, jboolean once) {
|
||||
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
|
||||
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
|
||||
EXPECT_EQ(once, JNI_FALSE);
|
||||
const int handle = ++g_probe->subscriptions;
|
||||
if (handle == g_probe->fail_subscription_at) return nullptr;
|
||||
if (handle == g_probe->fail_subscription_at)
|
||||
return nullptr;
|
||||
g_probe->legacy[Copy(env, method)] = callback;
|
||||
if (g_probe->call_during_registration) InvokeLegacy(env, callback, kRequest);
|
||||
if (g_probe->call_during_registration)
|
||||
InvokeLegacy(env, callback, kRequest);
|
||||
jclass cls = env->FindClass("com/roblox/universalapp/messagebus/Connection");
|
||||
jmethodID ctor = env->GetMethodID(cls, "<init>", "(J)V");
|
||||
jobject connection = env->NewObject(cls, ctor, static_cast<jlong>(handle));
|
||||
@@ -140,18 +150,18 @@ void Disconnect(JNIEnv*, jobject, jlong handle) {
|
||||
|
||||
void SetHandler(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
jobject handler) {
|
||||
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
|
||||
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
|
||||
g_probe->handlers[Copy(env, method)] = handler;
|
||||
}
|
||||
|
||||
void ClearHandler(JNIEnv* env, jobject, jstring protocol, jstring) {
|
||||
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
|
||||
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
|
||||
++g_probe->native_cleared;
|
||||
}
|
||||
|
||||
void Publish(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
jstring response, jint code, jstring telemetry) {
|
||||
EXPECT_EQ(Copy(env, protocol), "PermissionsProtocol");
|
||||
EXPECT_EQ(Copy(env, protocol), g_probe->expected_protocol);
|
||||
EXPECT_EQ(Copy(env, telemetry), "{}");
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(g_probe->mutex);
|
||||
@@ -160,7 +170,9 @@ void Publish(JNIEnv* env, jobject, jstring protocol, jstring method,
|
||||
if (g_probe->block_publish) {
|
||||
g_probe->publish_entered = true;
|
||||
g_probe->changed.notify_all();
|
||||
g_probe->changed.wait(lock, [] { return g_probe->release_publish; });
|
||||
g_probe->changed.wait(lock, [] {
|
||||
return g_probe->release_publish;
|
||||
});
|
||||
}
|
||||
}
|
||||
if (g_probe->reenter) {
|
||||
@@ -185,11 +197,14 @@ class RobloxPermissionsBridgeTest : public testing::Test {
|
||||
bus = env->AllocObject(cls);
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
call_bridge.reset();
|
||||
bridge.reset();
|
||||
env->DeleteLocalRef(bus);
|
||||
g_probe = nullptr;
|
||||
}
|
||||
|
||||
Status Initialize(bool enabled = true) {
|
||||
bridge = std::make_unique<RobloxPermissionsBridge>(
|
||||
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare},
|
||||
@@ -200,10 +215,23 @@ class RobloxPermissionsBridgeTest : public testing::Test {
|
||||
enabled);
|
||||
return bridge->Initialize();
|
||||
}
|
||||
|
||||
Status InitializeCall() {
|
||||
probe.expected_protocol = "Call";
|
||||
call_bridge = std::make_unique<RobloxCallProtocolBridge>(
|
||||
JniEnvironmentProvider{vm.GetJavaVM(), &vm, Prepare},
|
||||
RobloxMessageBusSymbols{Subscribe, Disconnect, Publish, SetHandler,
|
||||
ClearHandler, Resolve},
|
||||
RobloxMessageBusObjects{bus, &probe, CreateRaw, ClearRaw, CreateAsync,
|
||||
ClearAsync});
|
||||
return call_bridge->Initialize();
|
||||
}
|
||||
|
||||
Probe::Published Legacy(const char* method, const char* payload = kRequest) {
|
||||
InvokeLegacy(env, probe.legacy.at(method), payload);
|
||||
return probe.published.back();
|
||||
}
|
||||
|
||||
Json Async(const char* method, const char* payload = kRequest,
|
||||
const char* id = "request-1") {
|
||||
InvokeAsync(env, probe.handlers.at(method), payload, id);
|
||||
@@ -215,8 +243,38 @@ class RobloxPermissionsBridgeTest : public testing::Test {
|
||||
JNIEnv* env = nullptr;
|
||||
jobject bus = nullptr;
|
||||
std::unique_ptr<RobloxPermissionsBridge> bridge;
|
||||
std::unique_ptr<RobloxCallProtocolBridge> call_bridge;
|
||||
};
|
||||
|
||||
TEST_F(RobloxPermissionsBridgeTest,
|
||||
CallStateResolvesBothTransportsWithoutStartingCall) {
|
||||
ASSERT_TRUE(InitializeCall().ok());
|
||||
ASSERT_EQ(probe.legacy.size(), 1u);
|
||||
ASSERT_EQ(probe.handlers.size(), 1u);
|
||||
const auto legacy = Legacy("getCallState", "{}");
|
||||
EXPECT_EQ(legacy.code, 0);
|
||||
EXPECT_EQ(legacy.body["status"], "Idle");
|
||||
EXPECT_EQ(legacy.body["muted"], true);
|
||||
EXPECT_EQ(legacy.body["camEnabled"], false);
|
||||
const auto asynchronous = Async("getCallState", "{}", "voice-init-1");
|
||||
EXPECT_EQ(asynchronous, legacy.body);
|
||||
ASSERT_EQ(probe.resolved.size(), 1u);
|
||||
EXPECT_EQ(probe.resolved[0].first, "voice-init-1");
|
||||
}
|
||||
|
||||
TEST_F(RobloxPermissionsBridgeTest, CallStateShutdownDisablesStaleCallbacks) {
|
||||
ASSERT_TRUE(InitializeCall().ok());
|
||||
jobject handler = env->NewGlobalRef(probe.handlers.at("getCallState"));
|
||||
ASSERT_TRUE(call_bridge->Shutdown().ok());
|
||||
InvokeAsync(env, handler, "{}", "stale");
|
||||
EXPECT_TRUE(probe.published.empty());
|
||||
EXPECT_TRUE(probe.resolved.empty());
|
||||
EXPECT_EQ(probe.disconnected, 1);
|
||||
EXPECT_EQ(probe.native_cleared, 1);
|
||||
EXPECT_EQ(probe.handlers_cleared, 1);
|
||||
env->DeleteGlobalRef(handler);
|
||||
}
|
||||
|
||||
TEST_F(RobloxPermissionsBridgeTest, RegistersBothTransportsAndReinitializes) {
|
||||
ASSERT_TRUE(Initialize().ok());
|
||||
EXPECT_EQ(probe.legacy.size(), 5u);
|
||||
@@ -395,12 +453,14 @@ TEST_F(RobloxPermissionsBridgeTest,
|
||||
bool entered;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(probe.mutex);
|
||||
entered = probe.changed.wait_for(lock, std::chrono::seconds(2),
|
||||
[&] { return probe.publish_entered; });
|
||||
entered = probe.changed.wait_for(lock, std::chrono::seconds(2), [&] {
|
||||
return probe.publish_entered;
|
||||
});
|
||||
}
|
||||
EXPECT_TRUE(entered);
|
||||
auto shutdown =
|
||||
std::async(std::launch::async, [&] { return bridge->Shutdown(); });
|
||||
auto shutdown = std::async(std::launch::async, [&] {
|
||||
return bridge->Shutdown();
|
||||
});
|
||||
EXPECT_EQ(shutdown.wait_for(std::chrono::milliseconds(30)),
|
||||
std::future_status::timeout);
|
||||
{
|
||||
|
||||
@@ -281,6 +281,51 @@ TEST(RobloxTextInputJniBridgeTest,
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
IdenticalPendingShowsOpenOnlyOneFocusSession) {
|
||||
jnivm::VM vm;
|
||||
auto backend = std::make_shared<FakeBackend>();
|
||||
std::unique_ptr<RobloxTextInputJniBridge> bridge;
|
||||
ASSERT_TRUE(
|
||||
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
|
||||
const auto request = ShowRequest(42, "draft");
|
||||
for (int i = 0; i < 80; ++i) {
|
||||
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
|
||||
}
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
EXPECT_EQ(backend->calls, (std::vector<std::string>{"begin:1", "show:1"}));
|
||||
EXPECT_TRUE(backend->active);
|
||||
EXPECT_EQ(backend->last_initial_text, "draft");
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
SameTextBoxCanReopenAfterHostCompletionWithoutNativeHide) {
|
||||
for (const bool pump_completion_first : {false, true}) {
|
||||
SCOPED_TRACE(pump_completion_first);
|
||||
jnivm::VM vm;
|
||||
auto backend = std::make_shared<FakeBackend>();
|
||||
std::unique_ptr<RobloxTextInputJniBridge> bridge;
|
||||
ASSERT_TRUE(
|
||||
RobloxTextInputJniBridge::CreateForTesting(&vm, backend, &bridge).ok());
|
||||
const auto request = ShowRequest(42, "draft");
|
||||
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
|
||||
backend->active = false;
|
||||
if (pump_completion_first) {
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
}
|
||||
ASSERT_TRUE(vm.DispatchRobloxTextInputShow(request));
|
||||
ASSERT_TRUE(backend->Pump());
|
||||
|
||||
EXPECT_TRUE(backend->active);
|
||||
EXPECT_EQ(backend->active_handle, 42);
|
||||
EXPECT_EQ(backend->active_generation, 2U);
|
||||
EXPECT_TRUE(bridge->Shutdown().ok());
|
||||
}
|
||||
}
|
||||
|
||||
TEST(RobloxTextInputJniBridgeTest,
|
||||
PendingReplaceCallbacksCollapseToLatestTextInCurrentGeneration) {
|
||||
jnivm::VM vm;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 Mocktail Project Authors
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
readonly ROOT="${1:?source root is required}"
|
||||
readonly TEST_ROOT="$(mktemp -d)"
|
||||
trap 'rm -rf -- "${TEST_ROOT}"' EXIT
|
||||
|
||||
source "${ROOT}/scripts/package_standalone_webkit.sh"
|
||||
GDK_PIXBUF_SOURCE="${TEST_ROOT}/source"
|
||||
STAGING="${TEST_ROOT}/staging"
|
||||
readonly CACHE_REL=lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders.cache
|
||||
mkdir -p -- "${GDK_PIXBUF_SOURCE}" "${STAGING}/${CACHE_REL%/*}"
|
||||
cat >"${GDK_PIXBUF_SOURCE}/loaders.cache" <<'EOF'
|
||||
# LoaderDir = /usr/lib/gdk-pixbuf-2.0/2.10.0/loaders
|
||||
"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so"
|
||||
"svg" 6 "gdk-pixbuf" "Scalable Vector Graphics" "LGPL"
|
||||
"/usr/lib/gdk-pixbuf-2.0/2.10.0/loaders/io-wmf.so"
|
||||
"wmf" 0 "gdk-pixbuf" "Windows Metafile" "LGPL"
|
||||
"/custom/loaders/extra-loader.so"
|
||||
"xpm" 0 "gdk-pixbuf" "XPM" "LGPL"
|
||||
"/* XPM */" "" 100
|
||||
EOF
|
||||
cat >"${TEST_ROOT}/expected.cache" <<'EOF'
|
||||
# LoaderDir = lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders
|
||||
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/libpixbufloader-svg.so"
|
||||
"svg" 6 "gdk-pixbuf" "Scalable Vector Graphics" "LGPL"
|
||||
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/io-wmf.so"
|
||||
"wmf" 0 "gdk-pixbuf" "Windows Metafile" "LGPL"
|
||||
"lib/plugins/gdk-pixbuf-2.0/2.10.0/loaders/extra-loader.so"
|
||||
"xpm" 0 "gdk-pixbuf" "XPM" "LGPL"
|
||||
"/* XPM */" "" 100
|
||||
EOF
|
||||
|
||||
WriteClassicPixbufCache
|
||||
cmp -- "${TEST_ROOT}/expected.cache" "${STAGING}/${CACHE_REL}"
|
||||
|
||||
# Unknown absolute entries must also be rejected outside the usual /usr tree.
|
||||
printf '"/custom/unrecognized-loader"\n' >"${GDK_PIXBUF_SOURCE}/loaders.cache"
|
||||
if bash -c 'source "$1"; GDK_PIXBUF_SOURCE="$2"; STAGING="$3"; WriteClassicPixbufCache' \
|
||||
bash "${ROOT}/scripts/package_standalone_webkit.sh" \
|
||||
"${GDK_PIXBUF_SOURCE}" "${STAGING}" >"${TEST_ROOT}/rejected.log" 2>&1; then
|
||||
printf 'absolute GdkPixbuf loader path was accepted\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
grep -Fq 'absolute loader path' "${TEST_ROOT}/rejected.log"
|
||||
@@ -96,6 +96,21 @@ TEST(TextureOverrideTest, ResampleAveragesSourceBoxes) {
|
||||
EXPECT_EQ(grown[pixel * 4 + 0], 9);
|
||||
EXPECT_EQ(grown[pixel * 4 + 3], 6);
|
||||
}
|
||||
|
||||
// 4x4 image with row stride = 16 bytes, but we only resample the top-left 2x2 region
|
||||
std::vector<std::uint8_t> padded(4 * 16, 0);
|
||||
for (std::uint32_t y = 0; y < 2; ++y) {
|
||||
for (std::uint32_t x = 0; x < 2; ++x) {
|
||||
padded[y * 16 + x * 4 + 0] = source.pixels[(y * 2 + x) * 4 + 0];
|
||||
padded[y * 16 + x * 4 + 1] = source.pixels[(y * 2 + x) * 4 + 1];
|
||||
padded[y * 16 + x * 4 + 2] = source.pixels[(y * 2 + x) * 4 + 2];
|
||||
padded[y * 16 + x * 4 + 3] = source.pixels[(y * 2 + x) * 4 + 3];
|
||||
}
|
||||
}
|
||||
std::uint8_t strided_four[16] = {};
|
||||
ResampleRgba(padded.data(), 2, 2, 16, 2, 2, strided_four);
|
||||
EXPECT_EQ(std::vector<std::uint8_t>(strided_four, strided_four + 16),
|
||||
source.pixels);
|
||||
}
|
||||
|
||||
// Rounded mean of the source box [x*sw/w, (x+1)*sw/w) per axis, at least one
|
||||
|
||||
@@ -5,7 +5,14 @@
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <thread>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#include "window/window.h"
|
||||
@@ -105,6 +112,68 @@ TEST(VideoDriverPolicyTest, RejectsUnavailableOrEmptyDriverList) {
|
||||
EXPECT_FALSE(HasAvailableVideoDriverCandidate("", available));
|
||||
}
|
||||
|
||||
class NvidiaDriverDetectionTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
char pattern[] = "/tmp/mocktail_video_driver_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
ASSERT_NE(created, nullptr);
|
||||
root_ = created;
|
||||
proc_version_ = root_ / "proc/driver/nvidia/version";
|
||||
pci_drivers_ = root_ / "sys/bus/pci/drivers";
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(root_, error);
|
||||
}
|
||||
|
||||
bool Detect() const {
|
||||
return HasNvidiaKernelDriver(proc_version_, pci_drivers_ / "nvidia");
|
||||
}
|
||||
|
||||
std::filesystem::path root_;
|
||||
std::filesystem::path proc_version_;
|
||||
std::filesystem::path pci_drivers_;
|
||||
};
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, DetectsProcDriverWithoutSysfs) {
|
||||
std::filesystem::create_directories(proc_version_.parent_path());
|
||||
std::ofstream(proc_version_) << "NVRM version: NVIDIA UNIX Kernel Module\n";
|
||||
|
||||
EXPECT_TRUE(Detect());
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, HybridGpuUsesXwaylandWhenProcIsHidden) {
|
||||
std::filesystem::create_directories(pci_drivers_ / "i915");
|
||||
std::filesystem::create_directories(pci_drivers_ / "nvidia");
|
||||
VideoDriverPolicyInput input = NvidiaWaylandDirectVulkan();
|
||||
input.has_nvidia_kernel_driver = Detect();
|
||||
|
||||
EXPECT_TRUE(input.has_nvidia_kernel_driver);
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input),
|
||||
VideoDriverChoice::kNvidiaDirectVulkanX11);
|
||||
input.force_wayland = true;
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kWayland);
|
||||
input.has_explicit_sdl_driver = true;
|
||||
EXPECT_EQ(ResolveVideoDriverChoice(input), VideoDriverChoice::kSdlDefault);
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, DoesNotTreatNouveauAsTheNvidiaDriver) {
|
||||
std::filesystem::create_directories(pci_drivers_ / "nouveau");
|
||||
std::filesystem::create_directories(pci_drivers_ / "i915");
|
||||
std::filesystem::create_directories(pci_drivers_ / "amdgpu");
|
||||
|
||||
EXPECT_FALSE(Detect());
|
||||
}
|
||||
|
||||
TEST_F(NvidiaDriverDetectionTest, IgnoresMissingAndUnrelatedDeviceInformation) {
|
||||
EXPECT_FALSE(Detect());
|
||||
std::filesystem::create_directories(pci_drivers_ / "nvidia_drm");
|
||||
std::ofstream(pci_drivers_ / "nvidia") << "not a registered driver\n";
|
||||
EXPECT_FALSE(Detect());
|
||||
}
|
||||
|
||||
TEST(VideoDriverPolicyIntegrationTest,
|
||||
DropsUnavailableLegacyOverrideBeforeSdlInit) {
|
||||
bool has_dummy_driver = false;
|
||||
@@ -129,6 +198,127 @@ TEST(VideoDriverPolicyIntegrationTest,
|
||||
Shutdown();
|
||||
}
|
||||
|
||||
class AngleWindowInitializationTest : public ::testing::Test {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
char pattern[] = "/tmp/mocktail_angle_window_XXXXXX";
|
||||
const char* created = mkdtemp(pattern);
|
||||
ASSERT_NE(created, nullptr);
|
||||
directory_ = created;
|
||||
for (const char* name : {"MOCKTAIL_GRAPHICS_BACKEND", "MOCKTAIL_ANGLE_LIB_DIR",
|
||||
"MOCKTAIL_EGL_LIBRARY", "MOCKTAIL_GLES_LIBRARY",
|
||||
"MOCKTAIL_DISABLE_AUTO_ANGLE_FALLBACK",
|
||||
"MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS",
|
||||
"MOCKTAIL_SOFTWARE_WINDOW_FALLBACK",
|
||||
"ANGLE_DEFAULT_PLATFORM", "NODEVICE_SELECT",
|
||||
"DISABLE_LAYER_MESA_ANTI_LAG",
|
||||
"SDL_VIDEO_DRIVER", "SDL_VIDEODRIVER",
|
||||
"SDL_EGL_LIBRARY", "SDL_OPENGL_LIBRARY"}) {
|
||||
const char* value = std::getenv(name);
|
||||
environment_.emplace_back(name, value != nullptr
|
||||
? std::optional<std::string>(value)
|
||||
: std::nullopt);
|
||||
ASSERT_EQ(unsetenv(name), 0);
|
||||
}
|
||||
ASSERT_EQ(setenv("SDL_VIDEO_DRIVER", "dummy", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "angle-vulkan", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_ANGLE_LIB_DIR", directory_.c_str(), 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_DISABLE_AUTO_ANGLE_FALLBACK", "1", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS", "1", 1), 0);
|
||||
}
|
||||
|
||||
void TearDown() override {
|
||||
Shutdown();
|
||||
SDL_Quit();
|
||||
SDL_ResetHint(SDL_HINT_EGL_LIBRARY);
|
||||
SDL_ResetHint(SDL_HINT_OPENGL_LIBRARY);
|
||||
SDL_ResetHint(SDL_HINT_OPENGL_ES_DRIVER);
|
||||
SDL_ResetHint(SDL_HINT_VIDEO_DRIVER);
|
||||
for (const auto& [name, value] : environment_) {
|
||||
if (value) {
|
||||
setenv(name.c_str(), value->c_str(), 1);
|
||||
} else {
|
||||
unsetenv(name.c_str());
|
||||
}
|
||||
}
|
||||
std::error_code error;
|
||||
std::filesystem::remove_all(directory_, error);
|
||||
}
|
||||
|
||||
std::filesystem::path directory_;
|
||||
std::vector<std::pair<std::string, std::optional<std::string>>> environment_;
|
||||
};
|
||||
|
||||
TEST_F(AngleWindowInitializationTest, MissingAngleNeverFallsBackToSystemEgl) {
|
||||
EXPECT_FALSE(Init(320, 180, "missing ANGLE"));
|
||||
EXPECT_FALSE(IsInitialised());
|
||||
EXPECT_EQ(SDL_WasInit(SDL_INIT_VIDEO), 0U);
|
||||
}
|
||||
|
||||
TEST_F(AngleWindowInitializationTest, PartialAngleNeverMixesWithInstalledBrowsers) {
|
||||
std::ofstream(directory_ / "libEGL.so") << "fixture";
|
||||
EXPECT_FALSE(Init(320, 180, "partial ANGLE"));
|
||||
EXPECT_FALSE(IsInitialised());
|
||||
EXPECT_EQ(SDL_WasInit(SDL_INIT_VIDEO), 0U);
|
||||
}
|
||||
|
||||
TEST_F(AngleWindowInitializationTest, UnloadableAngleNeverCreatesWaitingWindow) {
|
||||
std::ofstream(directory_ / "libEGL.so") << "invalid EGL library";
|
||||
std::ofstream(directory_ / "libGLESv2.so") << "invalid GLES library";
|
||||
EXPECT_FALSE(Init(320, 180, "unloadable ANGLE"));
|
||||
EXPECT_FALSE(IsInitialised());
|
||||
}
|
||||
|
||||
TEST_F(AngleWindowInitializationTest, ReinitializationUsesCurrentBackend) {
|
||||
ASSERT_EQ(unsetenv("MOCKTAIL_ANGLE_LIB_DIR"), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "system", 1), 0);
|
||||
ASSERT_TRUE(Init(320, 180, "initial backend"));
|
||||
Shutdown();
|
||||
|
||||
ASSERT_EQ(setenv("MOCKTAIL_GRAPHICS_BACKEND", "direct-vulkan", 1), 0);
|
||||
EXPECT_FALSE(Init(320, 180, "new backend"));
|
||||
EXPECT_FALSE(IsInitialised());
|
||||
}
|
||||
|
||||
TEST_F(AngleWindowInitializationTest, RealAngleContextBindsOnRenderThread) {
|
||||
const char* directory = std::getenv("MOCKTAIL_TEST_ANGLE_LIB_DIR");
|
||||
if (directory == nullptr) {
|
||||
GTEST_SKIP() << "real ANGLE libraries were not explicitly provided";
|
||||
}
|
||||
ASSERT_EQ(setenv("MOCKTAIL_ANGLE_LIB_DIR", directory, 1), 0);
|
||||
ASSERT_EQ(setenv("SDL_VIDEO_DRIVER", "x11", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_ENABLE_TEST_GRAPHICS_STUBS", "0", 1), 0);
|
||||
ASSERT_EQ(setenv("MOCKTAIL_SOFTWARE_WINDOW_FALLBACK", "0", 1), 0);
|
||||
ASSERT_EQ(setenv("SDL_EGL_LIBRARY", "/missing/generic/libEGL.so", 1), 0);
|
||||
ASSERT_EQ(setenv("SDL_OPENGL_LIBRARY", "/missing/generic/libGLESv2.so", 1), 0);
|
||||
ASSERT_EQ(setenv("ANGLE_DEFAULT_PLATFORM", "opengl", 1), 0);
|
||||
ASSERT_TRUE(Init(320, 180, "ANGLE context handoff")) << SDL_GetError();
|
||||
ASSERT_EQ(SDL_GL_GetCurrentContext(), nullptr);
|
||||
|
||||
bool bound = false;
|
||||
bool released = false;
|
||||
std::string renderer;
|
||||
std::string error;
|
||||
std::thread render([&] {
|
||||
bound = MakeCurrentOnThread();
|
||||
if (bound) {
|
||||
using GetString = const unsigned char* (*)(unsigned int);
|
||||
const auto get_string =
|
||||
reinterpret_cast<GetString>(GetGLProcAddress("glGetString"));
|
||||
const unsigned char* value = get_string != nullptr ? get_string(0x1f01)
|
||||
: nullptr;
|
||||
if (value != nullptr) renderer = reinterpret_cast<const char*>(value);
|
||||
released = ReleaseCurrentOnThread();
|
||||
}
|
||||
error = SDL_GetError();
|
||||
});
|
||||
render.join();
|
||||
EXPECT_TRUE(bound) << error;
|
||||
EXPECT_TRUE(released) << error;
|
||||
EXPECT_NE(renderer.find("ANGLE"), std::string::npos) << renderer;
|
||||
EXPECT_NE(renderer.find("Vulkan"), std::string::npos) << renderer;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
} // namespace window
|
||||
} // namespace mocktail
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "mocktail/graphics/vulkan_text_overlay_compositor.h"
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
#include <libplacebo/vulkan.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
@@ -57,6 +58,76 @@ class VulkanTextOverlayCompositorTest : public ::testing::Test {
|
||||
}
|
||||
};
|
||||
|
||||
class VulkanTextOverlayCompositorDeviceTest
|
||||
: public VulkanTextOverlayCompositorTest {
|
||||
protected:
|
||||
void SetUp() override {
|
||||
VulkanTextOverlayCompositorTest::SetUp();
|
||||
pl_vulkan_params params = pl_vulkan_default_params;
|
||||
params.allow_software = true;
|
||||
params.async_compute = false;
|
||||
params.async_transfer = false;
|
||||
host_ = pl_vulkan_create(nullptr, ¶ms);
|
||||
if (host_ == nullptr) {
|
||||
GTEST_SKIP() << "A compatible Vulkan device is unavailable";
|
||||
}
|
||||
get_device_proc_addr_ = reinterpret_cast<PFN_vkGetDeviceProcAddr>(
|
||||
host_->get_proc_addr(host_->instance, "vkGetDeviceProcAddr"));
|
||||
ASSERT_NE(get_device_proc_addr_, nullptr);
|
||||
}
|
||||
|
||||
void TearDown() override { pl_vulkan_destroy(&host_); }
|
||||
|
||||
bool RegisterHostDevice(VulkanTextOverlayCompositor* compositor) {
|
||||
return compositor->RegisterDevice(
|
||||
host_->instance, host_->phys_device, host_->device, host_->api_version,
|
||||
host_->extensions, host_->num_extensions, host_->queue_graphics.index,
|
||||
host_->queue_graphics.count, host_->features, host_->get_proc_addr,
|
||||
get_device_proc_addr_);
|
||||
}
|
||||
|
||||
pl_vulkan host_ = nullptr;
|
||||
PFN_vkGetDeviceProcAddr get_device_proc_addr_ = nullptr;
|
||||
};
|
||||
|
||||
TEST_F(VulkanTextOverlayCompositorDeviceTest,
|
||||
RegisteredDeviceTeardownRemovesStateAndPreservesHost) {
|
||||
VulkanTextOverlayCompositor compositor;
|
||||
ASSERT_TRUE(RegisterHostDevice(&compositor));
|
||||
const auto get_device_queue = reinterpret_cast<PFN_vkGetDeviceQueue>(
|
||||
get_device_proc_addr_(host_->device, "vkGetDeviceQueue"));
|
||||
ASSERT_NE(get_device_queue, nullptr);
|
||||
VkQueue queue = VK_NULL_HANDLE;
|
||||
get_device_queue(host_->device, host_->queue_graphics.index, 0, &queue);
|
||||
ASSERT_NE(queue, VK_NULL_HANDLE);
|
||||
ASSERT_TRUE(compositor.RegisterQueue(host_->device, queue,
|
||||
host_->queue_graphics.index, 0));
|
||||
|
||||
compositor.DestroyDevice(VK_NULL_HANDLE);
|
||||
compositor.DestroyDevice(
|
||||
reinterpret_cast<VkDevice>(static_cast<uintptr_t>(0x51d)));
|
||||
ASSERT_TRUE(compositor.RegisterQueue(host_->device, queue,
|
||||
host_->queue_graphics.index, 0));
|
||||
|
||||
// Run with ThreadSanitizer to detect an unlock after DeviceState is freed.
|
||||
compositor.DestroyDevice(host_->device);
|
||||
EXPECT_FALSE(compositor.RegisterQueue(host_->device, queue,
|
||||
host_->queue_graphics.index, 0));
|
||||
compositor.DestroyDevice(host_->device);
|
||||
EXPECT_EQ(compositor.QueueSubmit(queue, 0, nullptr, VK_NULL_HANDLE,
|
||||
RecordingQueueSubmit),
|
||||
VK_SUCCESS);
|
||||
EXPECT_EQ(g_submit_calls.load(std::memory_order_relaxed), 1);
|
||||
EXPECT_EQ(g_last_queue.load(std::memory_order_relaxed), queue);
|
||||
|
||||
const auto device_wait_idle = reinterpret_cast<PFN_vkDeviceWaitIdle>(
|
||||
get_device_proc_addr_(host_->device, "vkDeviceWaitIdle"));
|
||||
ASSERT_NE(device_wait_idle, nullptr);
|
||||
EXPECT_EQ(device_wait_idle(host_->device), VK_SUCCESS);
|
||||
ASSERT_TRUE(RegisterHostDevice(&compositor));
|
||||
compositor.DestroyDevice(host_->device);
|
||||
}
|
||||
|
||||
TEST_F(VulkanTextOverlayCompositorTest,
|
||||
InactiveOverlayQueueSubmitInvokesFallback) {
|
||||
VulkanTextOverlayCompositor compositor;
|
||||
|
||||
@@ -134,6 +134,19 @@ TEST(WebViewHelperPolicyTest, HonorsExplicitCompositingEnvironment) {
|
||||
}
|
||||
}
|
||||
|
||||
TEST(WebViewHelperPolicyTest, ForcesCairoRendererWithSoftwareCompositing) {
|
||||
EXPECT_TRUE(ShouldForceSoftwareGtkRenderer(true, nullptr));
|
||||
EXPECT_FALSE(ShouldForceSoftwareGtkRenderer(false, nullptr));
|
||||
}
|
||||
|
||||
TEST(WebViewHelperPolicyTest, HonorsExplicitGtkRendererEnvironment) {
|
||||
for (bool software_compositing : {false, true}) {
|
||||
for (const char* value : {"gl", "vulkan", "cairo", ""}) {
|
||||
EXPECT_FALSE(ShouldForceSoftwareGtkRenderer(software_compositing, value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(WebViewHelperPolicyTest, AllowsOnlyTlsTopLevelNavigation) {
|
||||
const UriPolicyResult roblox =
|
||||
EvaluateNavigationUri("https://www.roblox.com/login");
|
||||
|
||||
@@ -2,11 +2,39 @@
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <unistd.h>
|
||||
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <cstdlib>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace mocktail {
|
||||
namespace window {
|
||||
|
||||
class WindowPointerCaptureOwnerTestPeer {
|
||||
public:
|
||||
static void WaitForClearingQuery(WindowPointerCaptureOwner* owner) {
|
||||
std::unique_lock<std::mutex> lock(owner->mutex_);
|
||||
owner->condition_.wait(lock, [owner] {
|
||||
return owner->clearing_ && owner->in_flight_ == 1 &&
|
||||
owner->callback_ == nullptr && owner->context_ == nullptr;
|
||||
});
|
||||
}
|
||||
|
||||
static bool ClearingQueryIsInFlight(WindowPointerCaptureOwner* owner) {
|
||||
std::lock_guard<std::mutex> lock(owner->mutex_);
|
||||
return owner->clearing_ && owner->in_flight_ == 1;
|
||||
}
|
||||
|
||||
static bool HasInFlightQuery(WindowPointerCaptureOwner* owner) {
|
||||
std::lock_guard<std::mutex> lock(owner->mutex_);
|
||||
return owner->in_flight_ != 0;
|
||||
}
|
||||
};
|
||||
|
||||
namespace {
|
||||
|
||||
class FakeBackend final : public PointerCaptureBackend {
|
||||
@@ -52,6 +80,126 @@ bool ClearQueryReentrantly(void* context, bool* locked_center) {
|
||||
return true;
|
||||
}
|
||||
|
||||
struct GatedQueryState {
|
||||
WindowPointerCaptureOwner* owner = nullptr;
|
||||
std::mutex mutex;
|
||||
std::condition_variable condition;
|
||||
bool reentrant = false;
|
||||
bool entered = false;
|
||||
bool allow_inner_clear = false;
|
||||
bool inner_clear_returned = false;
|
||||
bool allow_return = false;
|
||||
bool external_clear_returned = false;
|
||||
bool second_clear_started = false;
|
||||
bool second_clear_returned = false;
|
||||
};
|
||||
|
||||
bool GatedQuery(void* context, bool* locked_center) {
|
||||
auto* state = static_cast<GatedQueryState*>(context);
|
||||
std::unique_lock<std::mutex> lock(state->mutex);
|
||||
state->entered = true;
|
||||
state->condition.notify_all();
|
||||
if (state->reentrant) {
|
||||
state->condition.wait(lock, [state] { return state->allow_inner_clear; });
|
||||
lock.unlock();
|
||||
state->owner->ClearQuery();
|
||||
lock.lock();
|
||||
state->inner_clear_returned = true;
|
||||
state->condition.notify_all();
|
||||
}
|
||||
state->condition.wait(lock, [state] { return state->allow_return; });
|
||||
*locked_center = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void RunOverlappingClearScenario(bool reentrant) {
|
||||
// Every failure exits the child directly. A deadlock cannot strand a join or
|
||||
// a test destructor in the parent; the alarm bounds all waits in this child.
|
||||
alarm(8);
|
||||
FakeBackend backend;
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
GatedQueryState state;
|
||||
state.owner = &owner;
|
||||
state.reentrant = reentrant;
|
||||
if (!owner.RegisterQuery(GatedQuery, &state)) {
|
||||
std::_Exit(1);
|
||||
}
|
||||
std::thread pump([&] {
|
||||
if (!owner.Pump(false)) {
|
||||
std::_Exit(2);
|
||||
}
|
||||
});
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(state.mutex);
|
||||
state.condition.wait(lock, [&state] { return state.entered; });
|
||||
}
|
||||
std::thread external_clear([&] {
|
||||
owner.ClearQuery();
|
||||
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
|
||||
std::_Exit(7);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.external_clear_returned = true;
|
||||
state.condition.notify_all();
|
||||
});
|
||||
WindowPointerCaptureOwnerTestPeer::WaitForClearingQuery(&owner);
|
||||
|
||||
QueryState replacement;
|
||||
if (owner.RegisterQuery(Query, &replacement)) {
|
||||
std::_Exit(3);
|
||||
}
|
||||
std::thread second_clear;
|
||||
if (reentrant) {
|
||||
std::unique_lock<std::mutex> lock(state.mutex);
|
||||
state.allow_inner_clear = true;
|
||||
state.condition.notify_all();
|
||||
state.condition.wait(lock,
|
||||
[&state] { return state.inner_clear_returned; });
|
||||
} else {
|
||||
second_clear = std::thread([&] {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.second_clear_started = true;
|
||||
state.condition.notify_all();
|
||||
}
|
||||
owner.ClearQuery();
|
||||
if (WindowPointerCaptureOwnerTestPeer::HasInFlightQuery(&owner)) {
|
||||
std::_Exit(7);
|
||||
}
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
state.second_clear_returned = true;
|
||||
state.condition.notify_all();
|
||||
});
|
||||
std::unique_lock<std::mutex> lock(state.mutex);
|
||||
state.condition.wait(lock, [&state] { return state.second_clear_started; });
|
||||
}
|
||||
|
||||
if (!WindowPointerCaptureOwnerTestPeer::ClearingQueryIsInFlight(&owner) ||
|
||||
owner.RegisterQuery(Query, &replacement)) {
|
||||
std::_Exit(4);
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(state.mutex);
|
||||
if (state.external_clear_returned || state.second_clear_returned) {
|
||||
std::_Exit(5);
|
||||
}
|
||||
state.allow_return = true;
|
||||
state.condition.notify_all();
|
||||
}
|
||||
pump.join();
|
||||
external_clear.join();
|
||||
if (second_clear.joinable()) {
|
||||
second_clear.join();
|
||||
}
|
||||
if (!state.external_clear_returned ||
|
||||
(!reentrant && !state.second_clear_returned) ||
|
||||
!owner.RegisterQuery(Query, &replacement)) {
|
||||
std::_Exit(6);
|
||||
}
|
||||
owner.ClearQuery();
|
||||
std::_Exit(0);
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, FollowsNativeMouseLockState) {
|
||||
FakeBackend backend;
|
||||
QueryState query;
|
||||
@@ -135,6 +283,32 @@ TEST(WindowPointerCaptureOwnerTest, QueryCanClearItselfWithoutDeadlock) {
|
||||
EXPECT_TRUE(owner.RegisterQuery(Query, &replacement));
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, ClearRemovesQueryAndAllowsReplacement) {
|
||||
FakeBackend backend;
|
||||
QueryState query;
|
||||
WindowPointerCaptureOwner owner(&backend);
|
||||
ASSERT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
ASSERT_TRUE(owner.Pump(false));
|
||||
ASSERT_FALSE(owner.cursor_visible());
|
||||
|
||||
owner.ClearQuery();
|
||||
EXPECT_TRUE(owner.Pump(false));
|
||||
EXPECT_TRUE(owner.cursor_visible());
|
||||
EXPECT_TRUE(owner.RegisterQuery(Query, &query));
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest,
|
||||
QueryCanClearItselfWhileExternalClearWaitsForItsReturn) {
|
||||
ASSERT_EXIT(RunOverlappingClearScenario(true), ::testing::ExitedWithCode(0),
|
||||
"");
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest,
|
||||
ConcurrentExternalClearsWaitForQueryReturn) {
|
||||
ASSERT_EXIT(RunOverlappingClearScenario(false), ::testing::ExitedWithCode(0),
|
||||
"");
|
||||
}
|
||||
|
||||
TEST(WindowPointerCaptureOwnerTest, RightDragDoesNotCaptureOutsideAnExperience) {
|
||||
// The app surface has no camera. Capturing there would freeze the cursor
|
||||
// over Roblox's own menu UI.
|
||||
|
||||
Reference in new issue
Block a user