Resolve APK providers through a replaceable chain

The coordinator constructed ApkPureProvider directly in three places and
rejected every other configuration with "native updater supports only the
direct APKPure provider". A single unreachable host therefore failed the
whole update with no way to try anything else, and the reported error named
neither the provider nor the host: on 2026-08-18 api.pureapk.com answered
503 to every request, including metadata for unrelated packages, and a
first launch could only say "Roblox update preflight failed".

Introduce an ApkProvider interface and a ProviderChain that consults
providers in order, returns the first answer, and reports every failure
with the provider that produced it when none answer. Each provider
downloads into its own directory, so a failed attempt cannot block the
next. Adding a source is now one line in BuildProviderChain instead of a
change spread across the coordinator.

The chain currently holds APKPure alone. The repository already ships a
pinned Uptodown provider as a build script, but it no longer works against
Uptodown's current pages, so wiring it in would add a second way to fail
rather than a fallback; that is reported separately.

Payload receipts now record which provider produced an archive, and
updates.source accepts auto alongside apk-pure so existing configuration
files keep working.
This commit is contained in:
LightZirconite committed 2026-08-19 10:41:11 +02:00
1 parent b5eac34e0f
commit 9da2c02836
12 files changed
+275 -31

No files matched your search

+1
View File
@@ -215,6 +215,7 @@ pkg_check_modules(WEBKITGTK6 REQUIRED IMPORTED_TARGET webkitgtk-6.0)
add_library(mocktail_update STATIC
src/update/android_manifest.cc
src/update/apk_bundle.cc
src/update/apk_provider.cc
src/update/apk_signature.cc
src/update/apkpure_provider.cc
src/update/candidate_approval.cc
+3 -1
View File
@@ -118,7 +118,9 @@ updates:
# backend, and promote only on success. An existing current payload is
# preserved when probation fails.
automatic: true
# String (default: apk-pure): direct x86_64 APK provider.
# String (default: apk-pure): direct x86_64 APK provider selection. Both
# accepted values resolve the provider chain, which currently starts with
# APKPure. Supported values: auto, apk-pure.
source: apk-pure
# Reserved for desktop update integrations. `mocktail_updater` itself never
# launches another process after changing the active payload.
+69
View File
@@ -0,0 +1,69 @@
#ifndef MOCKTAIL_UPDATE_APK_PROVIDER_H_
#define MOCKTAIL_UPDATE_APK_PROVIDER_H_
#include <cstdint>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <vector>
namespace mocktail::update {
struct ProviderVersion {
std::string version_name;
std::uint64_t version_code = 0;
std::string error;
explicit operator bool() const { return error.empty(); }
};
struct ProviderDownloadResult {
std::vector<std::filesystem::path> archives;
// Provider that produced the archives, so a stored payload can be traced
// back to where it came from.
std::string source;
std::string error;
explicit operator bool() const { return error.empty(); }
};
// A source of Roblox APK archives. Providers are interchangeable: whichever
// one answers first wins, and every archive still passes the same signature,
// Build-ID, and ABI verification afterwards.
class ApkProvider {
public:
virtual ~ApkProvider() = default;
// Short stable identifier, recorded in payload receipts and error messages.
virtual std::string_view name() const = 0;
// Latest published version. A provider that only serves pinned versions
// reports an error here and stays usable through DownloadExact.
virtual ProviderVersion CheckLatest() const = 0;
virtual ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const = 0;
};
// Tries every provider in order. One unreachable provider is the most common
// first-run failure, and on its own it must not fail the whole update.
class ProviderChain final : public ApkProvider {
public:
void Add(std::unique_ptr<ApkProvider> provider);
bool empty() const { return providers_.empty(); }
std::string_view name() const override { return "provider-chain"; }
ProviderVersion CheckLatest() const override;
ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const override;
private:
std::vector<std::unique_ptr<ApkProvider>> providers_;
};
} // namespace mocktail::update
#endif // MOCKTAIL_UPDATE_APK_PROVIDER_H_
+7 -19
View File
@@ -1,42 +1,30 @@
#ifndef MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
#define MOCKTAIL_UPDATE_APKPURE_PROVIDER_H_
#include <cstdint>
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
#include "update/apk_provider.h"
namespace mocktail::update {
struct ProviderVersion {
std::string version_name;
std::uint64_t version_code = 0;
std::string error;
explicit operator bool() const { return error.empty(); }
};
struct ProviderDownloadResult {
std::vector<std::filesystem::path> archives;
std::string error;
explicit operator bool() const { return error.empty(); }
};
ProviderVersion ParseApkPureLatestMetadata(std::string_view metadata);
std::vector<std::string> ParseApkPureExactDownloadUrls(
std::string_view metadata, std::string_view version,
std::string* error = nullptr);
class ApkPureProvider final {
class ApkPureProvider final : public ApkProvider {
public:
ProviderVersion CheckLatest() const;
std::string_view name() const override { return "apk-pure"; }
ProviderVersion CheckLatest() const override;
ProviderDownloadResult DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd = -1) const;
int progress_fd = -1) const override;
};
} // namespace mocktail::update
+3 -1
View File
@@ -136,7 +136,9 @@ updates:
# backend, and promote only on success. An existing current payload is
# preserved when probation fails.
automatic: true
# String (default: apk-pure): direct x86_64 APK provider.
# String (default: apk-pure): direct x86_64 APK provider selection. Both
# accepted values resolve the provider chain, which currently starts with
# APKPure. Supported values: auto, apk-pure.
source: apk-pure
# Reserved for desktop update integrations. `mocktail_updater` itself never
# launches another process after changing the active payload.
+61
View File
@@ -0,0 +1,61 @@
#include "update/apk_provider.h"
#include <string>
#include <utility>
namespace mocktail::update {
namespace {
void AppendFailure(std::string* aggregate, std::string_view provider,
std::string_view failure) {
if (!aggregate->empty()) aggregate->append("; ");
aggregate->append(provider);
aggregate->append(": ");
aggregate->append(failure);
}
} // namespace
void ProviderChain::Add(std::unique_ptr<ApkProvider> provider) {
if (provider != nullptr) providers_.push_back(std::move(provider));
}
ProviderVersion ProviderChain::CheckLatest() const {
ProviderVersion result;
if (providers_.empty()) {
result.error = "no APK provider is configured";
return result;
}
std::string failures;
for (const std::unique_ptr<ApkProvider>& provider : providers_) {
const ProviderVersion version = provider->CheckLatest();
if (version) return version;
AppendFailure(&failures, provider->name(), version.error);
}
result.error = std::move(failures);
return result;
}
ProviderDownloadResult ProviderChain::DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const {
ProviderDownloadResult result;
if (providers_.empty()) {
result.error = "no APK provider is configured";
return result;
}
std::string failures;
for (const std::unique_ptr<ApkProvider>& provider : providers_) {
// Each provider gets its own directory: providers refuse to write into a
// directory another one already populated, and a failed attempt must not
// poison the next.
ProviderDownloadResult downloaded = provider->DownloadExact(
version, output_directory / provider->name(), progress_fd);
if (downloaded) return downloaded;
AppendFailure(&failures, provider->name(), downloaded.error);
}
result.error = std::move(failures);
return result;
}
} // namespace mocktail::update
+1
View File
@@ -208,6 +208,7 @@ ProviderDownloadResult ApkPureProvider::DownloadExact(
std::string_view version, const std::filesystem::path& output_directory,
int progress_fd) const {
ProviderDownloadResult result;
result.source = std::string(name());
if (version.empty() || version.size() > 128 ||
!std::all_of(version.begin(), version.end(), [](unsigned char character) {
return VersionCharacter(character);
+6 -2
View File
@@ -167,8 +167,12 @@ UpdateConfigResult LoadUpdateConfig(const std::filesystem::path& path) {
}
} else if (key == "source") {
result.config.source = Scalar(value);
if (result.config.source != "apk-pure") {
result.error = "updates.source must be apk-pure in the native updater";
// `apk-pure` predates the fallback chain and still selects it, so an
// existing configuration file keeps working unchanged.
if (result.config.source != "apk-pure" &&
result.config.source != "auto") {
result.error =
"updates.source must be auto or apk-pure in the native updater";
break;
}
} else {
+18 -7
View File
@@ -8,6 +8,7 @@
#include <cerrno>
#include <cstring>
#include <filesystem>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
@@ -16,6 +17,7 @@
#include "compat/elf_build_id.h"
#include "update/apk_bundle.h"
#include "update/apk_provider.h"
#include "update/apkpure_provider.h"
#include "update/compatibility_catalog.h"
#include "update/host_abi_deriver.h"
@@ -189,7 +191,15 @@ void RecordRejection(const UpdatePaths& paths, const Candidate& candidate,
close(descriptor);
}
Candidate DownloadCandidate(ApkPureProvider* provider, PayloadStore* store,
// Built once per run. Providers are consulted in order, so adding a second
// source is a one-line change here rather than a rewrite of the coordinator.
ProviderChain BuildProviderChain() {
ProviderChain chain;
chain.Add(std::make_unique<ApkPureProvider>());
return chain;
}
Candidate DownloadCandidate(const ApkProvider* provider, PayloadStore* store,
const ExpectedPayloadIdentity& identity,
bool exact_supported, const UpdatePaths& paths,
const std::filesystem::path& workspace,
@@ -207,7 +217,7 @@ Candidate DownloadCandidate(ApkPureProvider* provider, PayloadStore* store,
Progress(progress_fd, "Verifying Roblox...");
const PreparedPayload prepared = PreparePayloadFromArchives(
downloaded.archives, identity, paths.signing_trust_manifest,
workspace / "prepare", "apk-pure-native");
workspace / "prepare", downloaded.source + "-native");
if (!prepared) {
result.error = prepared.error;
return result;
@@ -230,7 +240,7 @@ struct ReferenceProfile {
ReferenceProfile ResolveReference(const UpdatePaths& paths,
const PayloadStoreResult& installed,
const SupportedPayloadProfile& preferred,
ApkPureProvider* provider,
const ApkProvider* provider,
PayloadStore* store,
const std::filesystem::path& workspace,
int progress_fd) {
@@ -290,7 +300,7 @@ UpdateResult RunUnsafeLatest(
return result;
}
ApkPureProvider provider;
const ProviderChain provider = BuildProviderChain();
Progress(request.progress_fd, "Checking latest Roblox...");
const ProviderVersion latest = provider.CheckLatest();
if (!latest) {
@@ -457,8 +467,9 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
result.error = configured.error;
return result;
}
if (configured.config.source != "apk-pure") {
result.error = "native updater supports only the direct APKPure provider";
if (configured.config.source != "apk-pure" &&
configured.config.source != "auto") {
result.error = "native updater supports only the direct APK providers";
return result;
}
const CompatibilityCatalogResult catalog =
@@ -503,7 +514,7 @@ UpdateResult RunUpdate(const UpdatePaths& paths, const UpdateRequest& request) {
"no runnable payload is installed and automatic updates are disabled";
return result;
}
ApkPureProvider provider;
const ProviderChain provider = BuildProviderChain();
std::optional<ProviderVersion> latest_version;
if (request.check_latest) {
Progress(request.progress_fd, "Checking Roblox...");
+7
View File
@@ -56,6 +56,12 @@ target_link_libraries(native_update_test PRIVATE
GTest::gtest_main
)
add_executable(apk_provider_test apk_provider_test.cc)
target_link_libraries(apk_provider_test PRIVATE
Mocktail::Update
GTest::gtest_main
)
add_executable(support_bundle_runtime_test support_bundle_runtime_test.cc)
target_link_libraries(support_bundle_runtime_test PRIVATE
Mocktail::Runtime
@@ -807,6 +813,7 @@ gtest_discover_tests(jnivm_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(support_bundle_runtime_test)
gtest_discover_tests(session_log_test)
+94
View File
@@ -0,0 +1,94 @@
#include "update/apk_provider.h"
#include <gtest/gtest.h>
#include <filesystem>
#include <memory>
#include <string>
#include <string_view>
#include <utility>
namespace mocktail::update {
namespace {
class StubProvider final : public ApkProvider {
public:
StubProvider(std::string name, std::string failure)
: name_(std::move(name)), failure_(std::move(failure)) {}
std::string_view name() const override { return name_; }
ProviderVersion CheckLatest() const override {
ProviderVersion version;
if (failure_.empty()) {
version.version_name = "2.727.1199";
version.version_code = 2628;
} else {
version.error = failure_;
}
return version;
}
ProviderDownloadResult DownloadExact(std::string_view,
const std::filesystem::path& directory,
int) const override {
ProviderDownloadResult result;
result.source = name_;
if (failure_.empty()) {
result.archives.push_back(directory / "candidate.apk");
} else {
result.error = failure_;
}
return result;
}
private:
std::string name_;
std::string failure_;
};
TEST(ProviderChainTest, FallsBackToTheNextProvider) {
ProviderChain chain;
chain.Add(std::make_unique<StubProvider>("apk-pure",
"api.pureapk.com request "
"returned status 503"));
chain.Add(std::make_unique<StubProvider>("pinned-mirror", ""));
const ProviderDownloadResult downloaded =
chain.DownloadExact("2.725.1142", "/tmp/mocktail-chain", -1);
ASSERT_TRUE(downloaded) << downloaded.error;
EXPECT_EQ(downloaded.source, "pinned-mirror");
ASSERT_EQ(downloaded.archives.size(), 1U);
// Each provider downloads into its own directory so a failed attempt cannot
// block the next one.
EXPECT_EQ(downloaded.archives.front(),
std::filesystem::path("/tmp/mocktail-chain/pinned-mirror/candidate.apk"));
}
TEST(ProviderChainTest, ReportsEveryProviderWhenAllFail) {
ProviderChain chain;
chain.Add(std::make_unique<StubProvider>("apk-pure", "returned status 503"));
chain.Add(std::make_unique<StubProvider>("pinned-mirror", "no pinned source"));
const ProviderDownloadResult downloaded =
chain.DownloadExact("2.734.917", "/tmp/mocktail-chain", -1);
EXPECT_FALSE(downloaded);
EXPECT_EQ(downloaded.error,
"apk-pure: returned status 503; pinned-mirror: no pinned source");
}
TEST(ProviderChainTest, PrefersTheFirstProviderThatAnswers) {
ProviderChain chain;
chain.Add(std::make_unique<StubProvider>("apk-pure", ""));
chain.Add(std::make_unique<StubProvider>("pinned-mirror", ""));
const ProviderVersion latest = chain.CheckLatest();
ASSERT_TRUE(latest) << latest.error;
EXPECT_EQ(latest.version_name, "2.727.1199");
}
TEST(ProviderChainTest, ReportsAnEmptyChain) {
const ProviderChain chain;
EXPECT_TRUE(chain.empty());
EXPECT_EQ(chain.CheckLatest().error, "no APK provider is configured");
}
} // namespace
} // namespace mocktail::update
+5 -1
View File
@@ -170,7 +170,11 @@ TEST(RuntimeConfigBootstrapTest,
"payload is\n "
"# preserved when probation fails.\n "
"automatic: true",
"# String (default: apk-pure): direct x86_64 APK provider.\n "
"# String (default: apk-pure): direct x86_64 APK provider "
"selection. Both\n "
"# accepted values resolve the provider chain, which currently "
"starts with\n "
"# APKPure. Supported values: auto, apk-pure.\n "
"source: apk-pure",
"# Reserved for desktop update integrations. `mocktail_updater` "
"itself never\n # launches another process after changing the "