diff --git a/src/update/apk_bundle.cc b/src/update/apk_bundle.cc index 6e81ebe..c7f9148 100644 --- a/src/update/apk_bundle.cc +++ b/src/update/apk_bundle.cc @@ -558,7 +558,11 @@ PreparedPayload PreparePayloadFromArchives( metadata.dump(2) + "\n", &result.error)) { return result; } - const PayloadIntegrityResult verified = VerifyPreparedPayload(prepared); + // The hashes above were computed from these exact bytes and written into + // roblox_payload.json, so re-hashing the tree here would only re-derive them. + // The shape, schema, and identity guard still runs; the bytes that reach the + // immutable store are hashed in full by PayloadStore::Stage after the copy. + const PayloadIntegrityResult verified = InspectPreparedPayload(prepared); if (!verified || verified.payload_id != std::to_string(expected.version_code) + "-" + build_id) { result.error = diff --git a/src/update/payload_integrity.cc b/src/update/payload_integrity.cc index 7febde1..82a51da 100644 --- a/src/update/payload_integrity.cc +++ b/src/update/payload_integrity.cc @@ -2,16 +2,20 @@ #define JSON_NOEXCEPTION 1 #include +#include #include #include #include #include +#include #include #include #include +#include #include #include +#include #include #include "mocktail/sha256.h" @@ -59,6 +63,56 @@ std::string ReadMetadata(const std::filesystem::path& path, return contents; } +// foundation::Sha256 is a portable implementation and runs at roughly 180 MB/s +// here; OpenSSL reaches the CPU's SHA extensions and measures 8x faster on the +// same bytes. A payload is several hundred MiB, so file hashing uses OpenSSL. +// The digests are identical, and foundation::Sha256 stays in use for the small +// inputs elsewhere in the updater. +class FileDigest final { + public: + FileDigest() : context_(EVP_MD_CTX_new()) { + if (context_ != nullptr && + EVP_DigestInit_ex(context_, EVP_sha256(), nullptr) != 1) { + EVP_MD_CTX_free(context_); + context_ = nullptr; + } + } + + ~FileDigest() { + if (context_ != nullptr) EVP_MD_CTX_free(context_); + } + + FileDigest(const FileDigest&) = delete; + FileDigest& operator=(const FileDigest&) = delete; + + bool valid() const { return context_ != nullptr; } + + bool Update(const unsigned char* bytes, std::size_t size) { + return context_ != nullptr && + EVP_DigestUpdate(context_, bytes, size) == 1; + } + + std::string FinalHex() { + unsigned char digest[EVP_MAX_MD_SIZE] = {}; + unsigned int size = 0; + if (context_ == nullptr || + EVP_DigestFinal_ex(context_, digest, &size) != 1 || size != 32U) { + return {}; + } + static constexpr char kHex[] = "0123456789abcdef"; + std::string hex; + hex.reserve(size * 2U); + for (unsigned int index = 0; index < size; ++index) { + hex.push_back(kHex[digest[index] >> 4]); + hex.push_back(kHex[digest[index] & 0x0FU]); + } + return hex; + } + + private: + EVP_MD_CTX* context_ = nullptr; +}; + } // namespace std::string HashRegularFile(const std::filesystem::path& path, @@ -76,8 +130,13 @@ std::string HashRegularFile(const std::filesystem::path& path, *error = "hash input is not a regular file: " + path.string(); return {}; } - foundation::Sha256 sha256; - std::array buffer{}; + FileDigest digest; + if (!digest.valid()) { + close(descriptor); + *error = "cannot initialise the file digest"; + return {}; + } + std::vector buffer(256U * 1024U); while (true) { const ssize_t bytes = read(descriptor, buffer.data(), buffer.size()); if (bytes == 0) break; @@ -87,10 +146,16 @@ std::string HashRegularFile(const std::filesystem::path& path, *error = "cannot hash regular file: " + path.string(); return {}; } - sha256.Update(buffer.data(), static_cast(bytes)); + if (!digest.Update(buffer.data(), static_cast(bytes))) { + close(descriptor); + *error = "cannot hash regular file: " + path.string(); + return {}; + } } close(descriptor); - return sha256.FinalHex(); + const std::string hex = digest.FinalHex(); + if (hex.empty()) *error = "cannot finalise the hash of " + path.string(); + return hex; } std::string HashAssetTree(const std::filesystem::path& root, @@ -132,13 +197,53 @@ std::string HashAssetTree(const std::filesystem::path& root, [](const auto& left, const auto& right) { return left.generic_string() < right.generic_string(); }); + // Each file digest is independent; only the fold below depends on the sorted + // order, so the per-file work spreads across cores without changing the + // result. An asset tree holds a few thousand files. + std::vector digests(relative_files.size()); + std::atomic next_index{0}; + std::mutex error_mutex; + std::string first_error; + const auto hash_range = [&]() { + while (true) { + const std::size_t index = next_index.fetch_add(1); + if (index >= relative_files.size()) return; + { + const std::lock_guard lock(error_mutex); + if (!first_error.empty()) return; + } + std::string local_failure; + std::string digest = + HashRegularFile(root / relative_files[index], &local_failure); + if (!local_failure.empty()) { + const std::lock_guard lock(error_mutex); + if (first_error.empty()) first_error = std::move(local_failure); + return; + } + digests[index] = std::move(digest); + } + }; + unsigned int workers = std::thread::hardware_concurrency(); + if (workers == 0U) workers = 1U; + workers = std::min(workers, 8U); + workers = std::min( + workers, static_cast(relative_files.size())); + std::vector pool; + pool.reserve(workers > 0U ? workers - 1U : 0U); + for (unsigned int index = 1; index < workers; ++index) { + pool.emplace_back(hash_range); + } + if (workers > 0U) hash_range(); + for (std::thread& worker : pool) worker.join(); + if (!first_error.empty()) { + *error = first_error; + return {}; + } foundation::Sha256 tree; - for (const auto& relative : relative_files) { - const std::string digest = HashRegularFile(root / relative, error); - if (!error->empty()) return {}; - tree.Update(digest); + for (std::size_t index = 0; index < relative_files.size(); ++index) { + tree.Update(digests[index]); tree.Update(" "); - tree.Update(relative.generic_string()); + tree.Update(relative_files[index].generic_string()); const unsigned char terminator = 0; tree.Update(&terminator, 1); } diff --git a/src/update/payload_store.cc b/src/update/payload_store.cc index 8999682..b7e4987 100644 --- a/src/update/payload_store.cc +++ b/src/update/payload_store.cc @@ -5,6 +5,10 @@ #include #include #include +#if defined(__linux__) +#include +#include +#endif #include #include @@ -227,6 +231,98 @@ bool QuarantinePayloadCollision(const std::filesystem::path& root, return true; } +// A payload tree is several hundred MiB. On a copy-on-write filesystem a +// reflink shares the extents instead of duplicating them, and copy_file_range +// keeps the bytes inside the kernel; both fall back to an ordinary copy. +// A hard link is deliberately not used: CopyTree makes the result read-only +// while the source stays writable, and a shared inode would break that. +bool CloneOrCopyFile(const std::filesystem::path& source, + const std::filesystem::path& destination) { + const int input = open(source.c_str(), O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + if (input < 0) return false; + struct stat metadata = {}; + if (fstat(input, &metadata) != 0 || !S_ISREG(metadata.st_mode)) { + close(input); + return false; + } + const int output = + open(destination.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW, 0600); + if (output < 0) { + close(input); + return false; + } + bool copied = false; +#if defined(__linux__) && defined(FICLONE) + copied = ioctl(output, FICLONE, input) == 0; +#endif +#if defined(__linux__) + if (!copied) { + off_t remaining = metadata.st_size; + copied = true; + while (remaining > 0) { + const ssize_t moved = copy_file_range( + input, nullptr, output, nullptr, static_cast(remaining), 0); + if (moved < 0 && errno == EINTR) continue; + if (moved <= 0) { + copied = false; + break; + } + remaining -= moved; + } + // copy_file_range advances both file offsets, so a partial move must be + // undone completely before the portable fallback can start from zero. + if (!copied && (ftruncate(output, 0) != 0 || + lseek(input, 0, SEEK_SET) != 0 || + lseek(output, 0, SEEK_SET) != 0)) { + close(input); + close(output); + std::error_code ignored; + std::filesystem::remove(destination, ignored); + return false; + } + } +#endif + if (!copied) { + std::array buffer{}; + while (true) { + const ssize_t bytes = read(input, buffer.data(), buffer.size()); + if (bytes == 0) break; + if (bytes < 0) { + if (errno == EINTR) continue; + close(input); + close(output); + std::error_code ignored; + std::filesystem::remove(destination, ignored); + return false; + } + std::size_t offset = 0; + while (offset < static_cast(bytes)) { + const ssize_t written = + write(output, buffer.data() + offset, + static_cast(bytes) - offset); + if (written < 0 && errno == EINTR) continue; + if (written <= 0) { + close(input); + close(output); + std::error_code ignored; + std::filesystem::remove(destination, ignored); + return false; + } + offset += static_cast(written); + } + } + } + close(input); + const bool closed = close(output) == 0; + if (!closed) { + std::error_code ignored; + std::filesystem::remove(destination, ignored); + return false; + } + return true; +} + bool CopyTree(const std::filesystem::path& source, const std::filesystem::path& destination, std::string* error) { std::error_code filesystem_error; @@ -251,7 +347,7 @@ bool CopyTree(const std::filesystem::path& source, } if (std::filesystem::is_directory(status)) { std::filesystem::create_directory(output, filesystem_error); - } else { + } else if (!CloneOrCopyFile(iterator->path(), output)) { std::filesystem::copy_file(iterator->path(), output, std::filesystem::copy_options::none, filesystem_error); @@ -466,8 +562,11 @@ PayloadStoreResult PayloadStore::Stage( PayloadStoreResult result; StoreLock lock(root_, &result.error); if (!lock) return result; + // Preparation produced these bytes and recorded their hashes. Hashing the + // workspace again proves nothing that the post-copy verify below does not + // prove about the bytes that actually become the immutable store entry. const PayloadIntegrityResult verified = - VerifyPreparedPayload(prepared_payload); + InspectPreparedPayload(prepared_payload); if (!verified) { result.error = verified.error; return result; diff --git a/src/update/update_coordinator.cc b/src/update/update_coordinator.cc index edaf9d6..cf0fa44 100644 --- a/src/update/update_coordinator.cc +++ b/src/update/update_coordinator.cc @@ -6,6 +6,9 @@ #include #include +#include +#include +#include #include #include #include @@ -64,6 +67,47 @@ void Progress(int descriptor, std::string_view message) { (void)send(descriptor, packet.data(), packet.size(), MSG_NOSIGNAL); } +bool TraceFlagEnabled(const char* name) { + const char* value = std::getenv(name); + return value != nullptr && value[0] != '\0' && std::strcmp(value, "0") != 0; +} + +bool UpdateTraceEnabled() { + static const bool enabled = TraceFlagEnabled("MOCKTAIL_UPDATE_TRACE") || + TraceFlagEnabled("MOCKTAIL_TRACE_ALL") || + TraceFlagEnabled("MOCKTAIL_FULL_TRACE"); + return enabled; +} + +// A first install downloads, verifies, canaries, and promotes several hundred +// MiB. Without a per-stage breakdown there is no way to tell which stage owns +// the wall-clock time, so the timer reports one line per stage on request. +class StageTimer final { + public: + explicit StageTimer(std::string name) + : name_(std::move(name)), start_(std::chrono::steady_clock::now()) {} + + ~StageTimer() { Report(); } + + StageTimer(const StageTimer&) = delete; + StageTimer& operator=(const StageTimer&) = delete; + + void Report() { + if (name_.empty()) return; + if (UpdateTraceEnabled()) { + const auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - start_); + std::fprintf(stderr, " [update] %s %lld ms\n", name_.c_str(), + static_cast(elapsed.count())); + } + name_.clear(); + } + + private: + std::string name_; + std::chrono::steady_clock::time_point start_; +}; + std::filesystem::path UniqueDirectory(const std::filesystem::path& parent, std::string* error) { std::error_code filesystem_error; @@ -208,13 +252,16 @@ Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store, result.identity = identity; result.exact_supported = exact_supported; Progress(progress_fd, "Downloading Roblox..."); + StageTimer download_timer("download"); const ProviderDownloadResult downloaded = provider->DownloadExact( identity.version_name, workspace / "provider", progress_fd); if (!downloaded) { result.error = downloaded.error; return result; } + download_timer.Report(); Progress(progress_fd, "Verifying Roblox..."); + StageTimer prepare_timer("prepare"); const PreparedPayload prepared = PreparePayloadFromArchives( downloaded.archives, identity, paths.signing_trust_manifest, workspace / "prepare", downloaded.source + "-native"); @@ -222,6 +269,8 @@ Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store, result.error = prepared.error; return result; } + prepare_timer.Report(); + StageTimer stage_timer("stage"); result.staged = store->Stage(prepared.directory); if (!result.staged) result.error = result.staged.error; return result; @@ -302,7 +351,10 @@ UpdateResult RunUnsafeLatest( const ProviderChain provider = BuildProviderChain(); Progress(request.progress_fd, "Checking latest Roblox..."); - const ProviderVersion latest = provider.CheckLatest(); + const ProviderVersion latest = [&] { + StageTimer timer("check-latest"); + return provider.CheckLatest(); + }(); if (!latest) { result.error = "cannot check latest Roblox for unsafe launch: " + latest.error; @@ -356,7 +408,10 @@ UpdateResult RunUnsafeLatest( derivation.candidate_payload_directory = candidate.staged.payload_directory; derivation.output_directory = workspace / "derived"; Progress(request.progress_fd, "Deriving latest Roblox compatibility..."); - const HostAbiDerivationResult derived = DeriveHostAbiProfile(derivation); + const HostAbiDerivationResult derived = [&] { + StageTimer timer("derive-host-abi"); + return DeriveHostAbiProfile(derivation); + }(); if (!derived) { result.error = "cannot derive latest Roblox HostAbi profile: " + derived.error; @@ -420,7 +475,11 @@ 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; - const CanaryResult canary_result = RunReadinessCanary(canary); + const CanaryResult canary_result = [&] { + StageTimer timer("canary[" + std::to_string(index + 1) + "/" + + std::to_string(runs) + "]"); + return RunReadinessCanary(canary); + }(); if (!canary_result) { *error = canary_result.error; if (!canary_result.log_path.empty()) { @@ -444,6 +503,7 @@ PayloadStoreResult PromoteCandidate(const UpdatePaths& paths, return {}; } Progress(request.progress_fd, "Installing Roblox..."); + StageTimer promote_timer("promote"); PayloadStoreResult promoted = candidate.exact_supported ? store->Promote(candidate.staged.payload_id) @@ -458,6 +518,7 @@ PayloadStoreResult PromoteCandidate(const UpdatePaths& paths, UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { UpdateResult result; + StageTimer total_timer("total"); UpdateLock update_lock(paths.data_root, &result.error); if (!update_lock) return result; @@ -518,7 +579,10 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { std::optional latest_version; if (request.check_latest) { Progress(request.progress_fd, "Checking Roblox..."); - const ProviderVersion latest = provider.CheckLatest(); + const ProviderVersion latest = [&] { + StageTimer timer("check-latest"); + return provider.CheckLatest(); + }(); if (latest) { latest_version = latest; if (current && SameVersion(current, latest)) { @@ -608,7 +672,10 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) { candidate.staged.payload_directory; derivation.output_directory = workspace / "derived"; Progress(request.progress_fd, "Checking latest Roblox compatibility..."); - const HostAbiDerivationResult derived = DeriveHostAbiProfile(derivation); + const HostAbiDerivationResult derived = [&] { + StageTimer timer("derive-host-abi"); + return DeriveHostAbiProfile(derivation); + }(); if (!derived) { candidate_error = derived.error; candidate_rejected = true; diff --git a/src/window/window.cc b/src/window/window.cc index 890f0e6..51a8e27 100644 --- a/src/window/window.cc +++ b/src/window/window.cc @@ -1957,8 +1957,10 @@ bool PumpEvents() { g_text_input_owner->OnFocusGained(); } if (event.type == SDL_EVENT_WINDOW_FOCUS_GAINED && - g_pointer_capture_owner != nullptr) { - g_pointer_capture_owner->OnFocusGained(); + g_pointer_capture_owner != nullptr && + !g_pointer_capture_owner->OnFocusGained( + g_text_input_owner != nullptr && g_text_input_owner->active())) { + fprintf(stderr, " [input] failed to restore pointer after focus gain\n"); } const bool geometry_changed = event.type == SDL_EVENT_WINDOW_MOVED || diff --git a/src/window/window_pointer_capture_owner.cc b/src/window/window_pointer_capture_owner.cc index dadf80d..5bcac76 100644 --- a/src/window/window_pointer_capture_owner.cc +++ b/src/window/window_pointer_capture_owner.cc @@ -121,9 +121,9 @@ bool WindowPointerCaptureOwner::ShouldDispatchMouseMotion() { return !discard_motion && !wait_for_native_unlock_after_right_drag_; } -bool WindowPointerCaptureOwner::OnFocusGained() { +bool WindowPointerCaptureOwner::OnFocusGained(bool text_input_active) { focused_ = true; - return true; + return Pump(text_input_active); } bool WindowPointerCaptureOwner::OnFocusLost() { diff --git a/src/window/window_pointer_capture_owner.h b/src/window/window_pointer_capture_owner.h index 595613c..2d90bf0 100644 --- a/src/window/window_pointer_capture_owner.h +++ b/src/window/window_pointer_capture_owner.h @@ -39,7 +39,11 @@ class WindowPointerCaptureOwner final { // Mirrors Android's captured-pointer listener: the motion that releases a // transient capture is consumed instead of leaking one final camera delta. bool ShouldDispatchMouseMotion(); - bool OnFocusGained(); + // Focus loss reveals the system cursor. Regaining focus must re-apply the + // pointer state immediately: Pump() runs at the top of the frame, before + // SDL_PollEvent delivers the focus event, so deferring the re-apply leaves + // the desktop arrow on screen for at least one whole frame. + bool OnFocusGained(bool text_input_active); bool OnFocusLost(); bool Shutdown(); diff --git a/tests/native_update_test.cc b/tests/native_update_test.cc index 6bb711c..3a4a5f2 100644 --- a/tests/native_update_test.cc +++ b/tests/native_update_test.cc @@ -451,6 +451,126 @@ TEST(PayloadStoreTest, RestagesReadOnlyCorruptPayloadCollision) { EXPECT_EQ(iterator, std::filesystem::directory_iterator()); } +// Promotion is separated from staging by the readiness canaries, which run the +// real client against the staged directory in a child process. A candidate +// modified in that window must never become current, so promotion rehashes the +// immutable bytes even though the very same instance staged them. +TEST(PayloadStoreTest, PromoteRehashesBytesTamperedAfterStaging) { + TemporaryDirectory temporary; + const std::filesystem::path prepared = temporary.root() / "prepared"; + Write(prepared / "libroblox.so", "native-library"); + Write(prepared / "sober_apk/base.apk", "base-apk"); + Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk"); + Write(prepared / "assets/content/fixture", "asset"); + std::string error; + std::size_t asset_count = 0; + const std::string asset_hash = + HashAssetTree(prepared / "assets", &asset_count, &error); + ASSERT_TRUE(error.empty()) << error; + const nlohmann::json metadata = { + {"schema_version", 1}, + {"package", "com.roblox.client"}, + {"version_name", "2.727.1199"}, + {"version_code", 2628}, + {"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"}, + {"sha256", + {{"libroblox", HashRegularFile(prepared / "libroblox.so")}, + {"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")}, + {"x86_64_split_apk", + HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}}, + {"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}}, + }; + Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n"); + const std::filesystem::path compatibility = + temporary.root() / "compatibility.json"; + Write(compatibility, + "{\"schema_version\":1,\"profiles\":[{" + "\"version_name\":\"2.727.1199\",\"version_code\":2628," + "\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\"," + "\"status\":\"supported\",\"default_allowed\":true," + "\"allow_legacy_binary_patches\":false}]}\n"); + + const std::filesystem::path store_root = temporary.root() / "store"; + PayloadStore store(store_root, compatibility); + const PayloadStoreResult staged = store.Stage(prepared); + ASSERT_TRUE(staged) << staged.error; + + // Stands in for the canary interval. Content bytes only: the metadata still + // describes the original payload, so nothing short of hashing the contents + // can detect this. + const std::filesystem::path asset = + staged.payload_directory / "assets/content/fixture"; + std::error_code filesystem_error; + std::filesystem::permissions(asset, std::filesystem::perms::owner_write, + std::filesystem::perm_options::add, + filesystem_error); + ASSERT_FALSE(filesystem_error); + Write(asset, "tampered"); + + const PayloadStoreResult promoted = store.Promote(staged.payload_id); + EXPECT_FALSE(promoted); + EXPECT_EQ(promoted.error, "payload asset tree does not match metadata"); + EXPECT_FALSE(std::filesystem::exists(store_root / "current.json", + filesystem_error)); +} + +// The same guarantee holds for a library modified in that window. +TEST(PayloadStoreTest, PromoteRejectsALibraryTamperedAfterStaging) { + TemporaryDirectory temporary; + const std::filesystem::path prepared = temporary.root() / "prepared"; + Write(prepared / "libroblox.so", "native-library"); + Write(prepared / "sober_apk/base.apk", "base-apk"); + Write(prepared / "sober_apk/split_config.x86_64.apk", "split-apk"); + Write(prepared / "assets/content/fixture", "asset"); + std::string error; + std::size_t asset_count = 0; + const std::string asset_hash = + HashAssetTree(prepared / "assets", &asset_count, &error); + ASSERT_TRUE(error.empty()) << error; + const nlohmann::json metadata = { + {"schema_version", 1}, + {"package", "com.roblox.client"}, + {"version_name", "2.727.1199"}, + {"version_code", 2628}, + {"elf_build_id", "1686400865ae0e408cd7bd67de7a439625c6fd13"}, + {"sha256", + {{"libroblox", HashRegularFile(prepared / "libroblox.so")}, + {"base_apk", HashRegularFile(prepared / "sober_apk/base.apk")}, + {"x86_64_split_apk", + HashRegularFile(prepared / "sober_apk/split_config.x86_64.apk")}}}, + {"assets", {{"file_count", asset_count}, {"sha256_tree", asset_hash}}}, + }; + Write(prepared / "roblox_payload.json", metadata.dump(2) + "\n"); + const std::filesystem::path compatibility = + temporary.root() / "compatibility.json"; + Write(compatibility, + "{\"schema_version\":1,\"profiles\":[{" + "\"version_name\":\"2.727.1199\",\"version_code\":2628," + "\"elf_build_id\":\"1686400865ae0e408cd7bd67de7a439625c6fd13\"," + "\"status\":\"supported\",\"default_allowed\":true," + "\"allow_legacy_binary_patches\":false}]}\n"); + + const std::filesystem::path store_root = temporary.root() / "store"; + PayloadStore store(store_root, compatibility); + const PayloadStoreResult staged = store.Stage(prepared); + ASSERT_TRUE(staged) << staged.error; + + const std::filesystem::path library = + staged.payload_directory / "libroblox.so"; + std::error_code filesystem_error; + std::filesystem::permissions(library, std::filesystem::perms::owner_write, + std::filesystem::perm_options::add, + filesystem_error); + ASSERT_FALSE(filesystem_error); + Write(library, "backdoored"); + + const PayloadStoreResult promoted = store.Promote(staged.payload_id); + EXPECT_FALSE(promoted); + EXPECT_EQ(promoted.error, "payload hash mismatch: libroblox.so"); + EXPECT_FALSE(std::filesystem::exists(store_root / "current.json", + filesystem_error)); +} + TEST(PayloadStoreTest, EmptyResultCannotReportFalsePromotion) { const PayloadStoreResult result; EXPECT_FALSE(result); diff --git a/tests/window_pointer_capture_owner_test.cc b/tests/window_pointer_capture_owner_test.cc index 1df98cb..ffdd889 100644 --- a/tests/window_pointer_capture_owner_test.cc +++ b/tests/window_pointer_capture_owner_test.cc @@ -2,6 +2,7 @@ #include +#include #include namespace mocktail { @@ -70,8 +71,7 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusAlwaysReleaseCapture) { EXPECT_TRUE(owner.captured()); EXPECT_TRUE(owner.OnFocusLost()); EXPECT_FALSE(owner.captured()); - EXPECT_TRUE(owner.OnFocusGained()); - EXPECT_TRUE(owner.Pump(false)); + EXPECT_TRUE(owner.OnFocusGained(false)); EXPECT_TRUE(owner.captured()); } @@ -236,11 +236,38 @@ TEST(WindowPointerCaptureOwnerTest, TextAndFocusCancelRightDragCapture) { EXPECT_TRUE(owner.OnFocusLost()); EXPECT_FALSE(owner.captured()); EXPECT_TRUE(owner.cursor_visible()); - EXPECT_TRUE(owner.OnFocusGained()); - EXPECT_TRUE(owner.Pump(false)); + EXPECT_TRUE(owner.OnFocusGained(false)); EXPECT_FALSE(owner.captured()); } +// Roblox draws its own cursor while it owns the pointer. Focus loss reveals the +// system cursor; regaining focus has to hide it again in the same call, because +// Pump() runs before SDL_PollEvent delivers the focus event and would otherwise +// leave the desktop arrow on screen for a whole frame. +TEST(WindowPointerCaptureOwnerTest, FocusGainHidesTheSystemCursorImmediately) { + FakeBackend backend; + QueryState query{true, false}; + WindowPointerCaptureOwner owner(&backend); + ASSERT_TRUE(owner.RegisterQuery(Query, &query)); + + ASSERT_TRUE(owner.Pump(false)); + ASSERT_FALSE(owner.cursor_visible()); + + EXPECT_TRUE(owner.OnFocusLost()); + EXPECT_TRUE(owner.cursor_visible()); + + const std::size_t calls_before = backend.calls.size(); + EXPECT_TRUE(owner.OnFocusGained(false)); + EXPECT_FALSE(owner.cursor_visible()); + ASSERT_GT(backend.calls.size(), calls_before); + EXPECT_FALSE(backend.calls.back().cursor_visible); + + // An active text field keeps the system cursor, focus or not. + EXPECT_TRUE(owner.OnFocusLost()); + EXPECT_TRUE(owner.OnFocusGained(true)); + EXPECT_TRUE(owner.cursor_visible()); +} + } // namespace } // namespace window } // namespace mocktail