From 5c5a686846768c676468c336e31f79d4849bed97 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:39 +0000 Subject: [PATCH 01/37] feat(system-service): add a strict validator for URLs opened on the host A URL a title hands to the host browser is untrusted. ValidateHostUrl accepts only http:// and https:// addresses of at most 2048 bytes made of RFC 3986 characters, rejects percent escapes that are malformed or decode to a control character, embedded credentials, an empty or malformed host and a port outside 1 to 65535. Nothing uses it yet. --- core/libs/prx/libSceSystemService/HostUrl.hpp | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 core/libs/prx/libSceSystemService/HostUrl.hpp diff --git a/core/libs/prx/libSceSystemService/HostUrl.hpp b/core/libs/prx/libSceSystemService/HostUrl.hpp new file mode 100644 index 000000000000..0e4dd82ebe8e --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrl.hpp @@ -0,0 +1,117 @@ +#pragma once + +#include +#include + +constexpr std::size_t HOST_URL_MAX_LENGTH = 2048; +constexpr std::size_t HOST_URL_MAX_PORT = 65535; + +enum class HostUrlVerdict { + Ok, + Empty, + TooLong, + BadCharacter, + BadEscape, + BadScheme, + BadAuthority, + Credentials, + BadPort, +}; + +namespace host_url { + +constexpr bool IsAlphaNumeric(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z'); +} + +constexpr bool IsHexDigit(char value) { + return (value >= '0' && value <= '9') || (value >= 'a' && value <= 'f') || (value >= 'A' && value <= 'F'); +} + +constexpr int HexValue(char value) { + if (value >= '0' && value <= '9') return value - '0'; + if (value >= 'a' && value <= 'f') return value - 'a' + 10; + return value - 'A' + 10; +} + +constexpr bool IsUrlCharacter(char value) { + if (IsAlphaNumeric(value)) return true; + for (const char allowed : std::string_view("-._~:/?#[]@!$&'()*+,;=%")) + if (value == allowed) return true; + return false; +} + +constexpr bool IsHostCharacter(char value) { + return IsAlphaNumeric(value) || value == '-' || value == '.' || value == '_'; +} + +constexpr HostUrlVerdict ValidateEscapes(std::string_view url) { + for (std::size_t index = 0; index < url.size(); ++index) { + if (url[index] != '%') continue; + if (index + 2 >= url.size()) return HostUrlVerdict::BadEscape; + if (!IsHexDigit(url[index + 1]) || !IsHexDigit(url[index + 2])) return HostUrlVerdict::BadEscape; + const int decoded = HexValue(url[index + 1]) * 16 + HexValue(url[index + 2]); + if (decoded < 0x20 || decoded == 0x7f) return HostUrlVerdict::BadEscape; + index += 2; + } + return HostUrlVerdict::Ok; +} + +constexpr HostUrlVerdict ValidatePort(std::string_view port) { + if (port.empty() || port.size() > 5) return HostUrlVerdict::BadPort; + std::size_t value = 0; + for (const char digit : port) { + if (digit < '0' || digit > '9') return HostUrlVerdict::BadPort; + value = value * 10 + static_cast(digit - '0'); + } + return value >= 1 && value <= HOST_URL_MAX_PORT ? HostUrlVerdict::Ok : HostUrlVerdict::BadPort; +} + +constexpr HostUrlVerdict ValidateAuthority(std::string_view authority) { + if (authority.empty()) return HostUrlVerdict::BadAuthority; + if (authority.find('@') != std::string_view::npos) return HostUrlVerdict::Credentials; + std::string_view host = authority; + std::string_view port; + bool hasPort = false; + if (authority.front() == '[') { + const std::size_t close = authority.find(']'); + if (close == std::string_view::npos || close == 1) return HostUrlVerdict::BadAuthority; + host = authority.substr(1, close - 1); + for (const char value : host) + if (!IsHexDigit(value) && value != ':' && value != '.') return HostUrlVerdict::BadAuthority; + const std::string_view rest = authority.substr(close + 1); + if (!rest.empty()) { + if (rest.front() != ':') return HostUrlVerdict::BadAuthority; + hasPort = true; + port = rest.substr(1); + } + } else { + const std::size_t colon = authority.find(':'); + if (colon != std::string_view::npos) { + host = authority.substr(0, colon); + hasPort = true; + port = authority.substr(colon + 1); + } + if (host.empty()) return HostUrlVerdict::BadAuthority; + for (const char value : host) + if (!IsHostCharacter(value)) return HostUrlVerdict::BadAuthority; + } + if (hasPort) return ValidatePort(port); + return HostUrlVerdict::Ok; +} + +} + +constexpr HostUrlVerdict ValidateHostUrl(std::string_view url) { + if (url.empty()) return HostUrlVerdict::Empty; + if (url.size() > HOST_URL_MAX_LENGTH) return HostUrlVerdict::TooLong; + for (const char value : url) + if (!host_url::IsUrlCharacter(value)) return HostUrlVerdict::BadCharacter; + if (const HostUrlVerdict escapes = host_url::ValidateEscapes(url); escapes != HostUrlVerdict::Ok) return escapes; + std::string_view rest = url; + if (rest.substr(0, 7) == "http://") rest.remove_prefix(7); + else if (rest.substr(0, 8) == "https://") rest.remove_prefix(8); + else return HostUrlVerdict::BadScheme; + const std::size_t end = rest.find_first_of("/?#"); + return host_url::ValidateAuthority(end == std::string_view::npos ? rest : rest.substr(0, end)); +} From 01d7f88a2f1c930f29efc14d6fb9fc09a8504bc4 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:46 +0000 Subject: [PATCH 02/37] test(system-service): cover the host URL validator Checks the accepted http and https forms (IPv6 literals, ports, query and fragment), the scheme, character, percent escape, authority, credential, port and length rejections, including look-alike credentials such as https://good.com@evil.com/ and control characters hidden in escapes. --- .../prx/libSceSystemService/CMakeLists.txt | 9 ++ core/libs/tests/GuestSystemServiceOpenUrl.cpp | 120 ++++++++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 core/libs/tests/GuestSystemServiceOpenUrl.cpp diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index 5885fd39d51e..98fdd68704db 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -97,6 +97,15 @@ if(BUILD_TESTING) "PATH=path_list_prepend:$") endif() endforeach() + add_test_executable(guest_system_service_open_url_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceOpenUrl.cpp) + target_include_directories(guest_system_service_open_url_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_system_service_open_url_tests PRIVATE libSceSystemService libc) + configure_windows_unwind(guest_system_service_open_url_tests) + add_test(NAME guest_system_service_open_url COMMAND guest_system_service_open_url_tests) + if(WIN32) + set_tests_properties(guest_system_service_open_url PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() add_test_executable(guest_system_service_language_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceLanguage.cpp) target_include_directories(guest_system_service_language_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_system_service_language_tests PRIVATE libSceSystemService libc) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp new file mode 100644 index 000000000000..90544ee9b071 --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -0,0 +1,120 @@ +#include "prx/libSceSystemService/HostUrl.hpp" +#include +#include + +namespace { + +void Require(bool value) { if (!value) std::abort(); } + +void CheckValidator() { + Require(ValidateHostUrl("http://example.com") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com:8443/a/b?c=d&e=f#g") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[2001:db8::1]:443/x") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[::1]/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("http://a_b.example-site.com?q=%41%20") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com#frag") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("") == HostUrlVerdict::Empty); +} + +void CheckScheme() { + Require(ValidateHostUrl("http:") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("https:") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("http:example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("psno://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("file:///etc/passwd") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("javascript:alert(1)") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("ftp://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("HTTP://example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("//example.com") == HostUrlVerdict::BadScheme); + Require(ValidateHostUrl("example.com") == HostUrlVerdict::BadScheme); +} + +void CheckCharacters() { + Require(ValidateHostUrl("https://example.com/a b") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\n") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\t") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl(std::string("https://example.com/\0x", 22)) == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\x7f") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\x1b[0m") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\xc3\xa9") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://\xd0\xb0pple.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\\x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/\"x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/`x`") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/|x") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://example.com/{x}") == HostUrlVerdict::BadCharacter); +} + +void CheckEscapes() { + Require(ValidateHostUrl("https://example.com/%") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%4") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%zz") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%00") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%0a") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%0D") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%1F") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%7f") == HostUrlVerdict::BadEscape); + Require(ValidateHostUrl("https://example.com/%20") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/%7E") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com/%c3%a9") == HostUrlVerdict::Ok); +} + +void CheckAuthority() { + Require(ValidateHostUrl("http://") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https:///path") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://?q=1") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://#x") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://:443/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://exa mple.com/") == HostUrlVerdict::BadCharacter); + Require(ValidateHostUrl("https://exam!ple.com/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://exam%41ple.com/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[]/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::g]/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::1/") == HostUrlVerdict::BadAuthority); + Require(ValidateHostUrl("https://[::1]x/") == HostUrlVerdict::BadAuthority); +} + +void CheckCredentials() { + Require(ValidateHostUrl("https://user@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://user:secret@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://@example.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://good.com@evil.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://good.com:443@evil.com/") == HostUrlVerdict::Credentials); + Require(ValidateHostUrl("https://example.com/path@x") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com?u=a@b") == HostUrlVerdict::Ok); +} + +void CheckPort() { + Require(ValidateHostUrl("https://example.com:/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:0/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:65536/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:123456/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:80a/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://example.com:65535/") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://example.com:1") == HostUrlVerdict::Ok); + Require(ValidateHostUrl("https://[::1]:/") == HostUrlVerdict::BadPort); + Require(ValidateHostUrl("https://[::1]:99999/") == HostUrlVerdict::BadPort); +} + +void CheckLength() { + const std::string prefix = "https://example.com/"; + Require(ValidateHostUrl(prefix + std::string(HOST_URL_MAX_LENGTH - prefix.size(), 'a')) == HostUrlVerdict::Ok); + Require(ValidateHostUrl(prefix + std::string(HOST_URL_MAX_LENGTH - prefix.size() + 1, 'a')) == HostUrlVerdict::TooLong); + Require(ValidateHostUrl(std::string(100000, 'a')) == HostUrlVerdict::TooLong); +} + +} + +int main() { + CheckValidator(); + CheckScheme(); + CheckCharacters(); + CheckEscapes(); + CheckAuthority(); + CheckCredentials(); + CheckPort(); + CheckLength(); + return 0; +} From b55a2878a077ec98c18a8ef73593297558520aa4 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:06:58 +0000 Subject: [PATCH 03/37] feat(system-service): parse ANYPS5_OPEN_URLS into a launch mode The variable selects never, ask or always. Unset and empty mean never, which is today's behaviour; any other value throws an error that names the variable. Nothing reads the mode yet. --- .../prx/libSceSystemService/HostUrlPolicy.hpp | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 core/libs/prx/libSceSystemService/HostUrlPolicy.hpp diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp new file mode 100644 index 000000000000..db392fdeb41b --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include +#include +#include +#include + +enum class OpenUrlMode { + Never, + Ask, + Always, +}; + +inline OpenUrlMode ParseOpenUrlMode(const char* configured, std::string_view caller) { + if (configured == nullptr || configured[0] == '\0') return OpenUrlMode::Never; + const std::string_view text(configured); + if (text == "never") return OpenUrlMode::Never; + if (text == "ask") return OpenUrlMode::Ask; + if (text == "always") return OpenUrlMode::Always; + throw std::runtime_error(std::string(caller) + ": ANYPS5_OPEN_URLS must be never, ask or always, not \"" + std::string(text) + "\""); +} + +inline OpenUrlMode OpenUrlModeFromEnvironment(std::string_view caller) { + return ParseOpenUrlMode(std::getenv("ANYPS5_OPEN_URLS"), caller); +} From af5ec46ec42a4e319ffa6fb6b2bdc5fd950b252f Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:07:10 +0000 Subject: [PATCH 04/37] test(system-service): cover ANYPS5_OPEN_URLS parsing Unset and empty give never, the three words are accepted, and anything else (case variants, padding, lists, numbers) throws with the variable name, the caller and the offending value in the message. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index 90544ee9b071..88d2e3ce2141 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -1,4 +1,9 @@ #include "prx/libSceSystemService/HostUrl.hpp" +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include +#ifdef _WIN32 +#include +#endif #include #include @@ -6,6 +11,15 @@ namespace { void Require(bool value) { if (!value) std::abort(); } +void SetEnvironment(const char* name, const char* value) { +#ifdef _WIN32 + Require(_putenv_s(name, value == nullptr ? "" : value) == 0); +#else + if (value == nullptr) Require(unsetenv(name) == 0); + else Require(setenv(name, value, 1) == 0); +#endif +} + void CheckValidator() { Require(ValidateHostUrl("http://example.com") == HostUrlVerdict::Ok); Require(ValidateHostUrl("https://example.com/") == HostUrlVerdict::Ok); @@ -105,6 +119,48 @@ void CheckLength() { Require(ValidateHostUrl(std::string(100000, 'a')) == HostUrlVerdict::TooLong); } +bool ModeThrows(const char* value, std::string& message) { + try { + static_cast(ParseOpenUrlMode(value, "caller")); + } catch (const std::runtime_error& error) { + message = error.what(); + return true; + } + return false; +} + +void CheckMode() { + Require(ParseOpenUrlMode(nullptr, "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("", "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("never", "caller") == OpenUrlMode::Never); + Require(ParseOpenUrlMode("ask", "caller") == OpenUrlMode::Ask); + Require(ParseOpenUrlMode("always", "caller") == OpenUrlMode::Always); + for (const char* invalid : {"Ask", "ALWAYS", "1", "yes", "ask ", " ask", "never,ask", "0", "on"}) { + std::string message; + Require(ModeThrows(invalid, message)); + Require(message.find("ANYPS5_OPEN_URLS") != std::string::npos); + Require(message.find("caller") != std::string::npos); + Require(message.find(invalid) != std::string::npos); + } + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Never); + SetEnvironment("ANYPS5_OPEN_URLS", ""); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Never); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Ask); + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + Require(OpenUrlModeFromEnvironment("caller") == OpenUrlMode::Always); + SetEnvironment("ANYPS5_OPEN_URLS", "bogus"); + bool threw = false; + try { + static_cast(OpenUrlModeFromEnvironment("caller")); + } catch (const std::runtime_error&) { + threw = true; + } + Require(threw); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); +} + } int main() { @@ -116,5 +172,6 @@ int main() { CheckCredentials(); CheckPort(); CheckLength(); + CheckMode(); return 0; } From 9144fbc4377a550d2e88be9ba96bee97250384e9 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:07:32 +0000 Subject: [PATCH 05/37] feat(system-service): inject the URL launcher and confirmation behind interfaces IUrlLauncher and IUrlConfirmation abstract the host browser and the user's approval. OpenUrlOnHost applies the mode: never does nothing, ask validates then asks then launches, always validates then launches. An address that fails the validator is never shown or launched in any mode. No backend exists yet. --- .../prx/libSceSystemService/HostUrlPolicy.hpp | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp index db392fdeb41b..d897bce1204d 100644 --- a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -4,6 +4,7 @@ #include #include #include +#include "prx/libSceSystemService/HostUrl.hpp" enum class OpenUrlMode { Never, @@ -23,3 +24,31 @@ inline OpenUrlMode ParseOpenUrlMode(const char* configured, std::string_view cal inline OpenUrlMode OpenUrlModeFromEnvironment(std::string_view caller) { return ParseOpenUrlMode(std::getenv("ANYPS5_OPEN_URLS"), caller); } + +class IUrlLauncher { +public: + virtual ~IUrlLauncher() = default; + virtual bool Launch(const std::string& url) = 0; +}; + +class IUrlConfirmation { +public: + virtual ~IUrlConfirmation() = default; + virtual bool Confirm(const std::string& url) = 0; +}; + +enum class OpenUrlOutcome { + Disabled, + Rejected, + Declined, + Failed, + Launched, +}; + +inline OpenUrlOutcome OpenUrlOnHost(OpenUrlMode mode, std::string_view url, IUrlLauncher& launcher, IUrlConfirmation& confirmation) { + if (mode == OpenUrlMode::Never) return OpenUrlOutcome::Disabled; + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return OpenUrlOutcome::Rejected; + const std::string address(url); + if (mode == OpenUrlMode::Ask && !confirmation.Confirm(address)) return OpenUrlOutcome::Declined; + return launcher.Launch(address) ? OpenUrlOutcome::Launched : OpenUrlOutcome::Failed; +} From 52e0c621a685fb1603b327bd3f60eb1de7fb35db Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:07:32 +0000 Subject: [PATCH 06/37] test(system-service): cover the open-URL policy with fake backends Records every launch and confirmation to prove that never touches neither, that a declined confirmation prevents the launch, that always skips the question, that a launcher failure is reported and that an invalid address reaches neither backend in any mode. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index 88d2e3ce2141..bb8e4bd340aa 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -6,6 +6,7 @@ #endif #include #include +#include namespace { @@ -161,6 +162,67 @@ void CheckMode() { SetEnvironment("ANYPS5_OPEN_URLS", nullptr); } +class RecordingLauncher final : public IUrlLauncher { +public: + bool Launch(const std::string& url) override { + launched.push_back(url); + return result; + } + std::vector launched; + bool result = true; +}; + +class ScriptedConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override { + asked.push_back(url); + return answer; + } + std::vector asked; + bool answer = true; +}; + +void CheckPolicy() { + const std::string good = "https://example.com/a?b=c"; + RecordingLauncher launcher; + ScriptedConfirmation confirmation; + + Require(OpenUrlOnHost(OpenUrlMode::Never, good, launcher, confirmation) == OpenUrlOutcome::Disabled); + Require(launcher.launched.empty() && confirmation.asked.empty()); + + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Launched); + Require(confirmation.asked == std::vector{good}); + Require(launcher.launched == std::vector{good}); + + confirmation.answer = false; + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Declined); + Require(confirmation.asked.size() == 2 && launcher.launched.size() == 1); + confirmation.answer = true; + + launcher.result = false; + Require(OpenUrlOnHost(OpenUrlMode::Ask, good, launcher, confirmation) == OpenUrlOutcome::Failed); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 2); + launcher.result = true; + + confirmation.answer = false; + Require(OpenUrlOnHost(OpenUrlMode::Always, good, launcher, confirmation) == OpenUrlOutcome::Launched); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 3); + confirmation.answer = true; + + for (const std::string& bad : {std::string("psno://example.com"), std::string("https://u:p@example.com/"), std::string("https://example.com/\n"), + std::string("http://"), std::string("")}) { + for (const OpenUrlMode mode : {OpenUrlMode::Ask, OpenUrlMode::Always}) { + const std::size_t asked = confirmation.asked.size(); + const std::size_t launched = launcher.launched.size(); + Require(OpenUrlOnHost(mode, bad, launcher, confirmation) == OpenUrlOutcome::Rejected); + Require(confirmation.asked.size() == asked && launcher.launched.size() == launched); + } + const std::size_t launched = launcher.launched.size(); + Require(OpenUrlOnHost(OpenUrlMode::Never, bad, launcher, confirmation) == OpenUrlOutcome::Disabled); + Require(launcher.launched.size() == launched); + } +} + } int main() { @@ -173,5 +235,6 @@ int main() { CheckPort(); CheckLength(); CheckMode(); + CheckPolicy(); return 0; } From 9b853a7c4b9e6af12bf05ebf1c9dc83ca8270c28 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:08:00 +0000 Subject: [PATCH 07/37] feat(system-service): launch URLs through xdg-open or open without a shell HostBrowserLauncher spawns the platform opener (xdg-open on Linux, /usr/bin/open on macOS) with posix_spawnp and an argument vector holding the address as one element, so no shell ever parses it. The opener's standard streams go to /dev/null and a detached thread reaps it. A spawn failure, such as a missing opener, is reported as a failed launch. Windows has no backend yet. --- .../prx/libSceSystemService/CMakeLists.txt | 3 + .../libSceSystemService/HostUrlBackends.hpp | 14 +++++ .../HostUrlLauncherPosix.cpp | 59 +++++++++++++++++++ 3 files changed, 76 insertions(+) create mode 100644 core/libs/prx/libSceSystemService/HostUrlBackends.hpp create mode 100644 core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index 98fdd68704db..92a99ad77bd1 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -4,6 +4,9 @@ if(APPLE) endif() add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp) +if(NOT WIN32) + target_sources(libSceSystemService PRIVATE HostUrlLauncherPosix.cpp) +endif() target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(libSceSystemService PRIVATE libSceAgcDriver libSceUsbStorage system_service_host_locale) configure_windows_unwind(libSceSystemService) diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp new file mode 100644 index 000000000000..5c688a0aac36 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include "prx/libSceSystemService/HostUrlPolicy.hpp" + +class HostBrowserLauncher final : public IUrlLauncher { +public: + HostBrowserLauncher(); + explicit HostBrowserLauncher(std::string program); + bool Launch(const std::string& url) override; + +private: + std::string program; +}; diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp new file mode 100644 index 000000000000..d9097bd349bd --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp @@ -0,0 +1,59 @@ +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#else +extern char** environ; +#endif + +namespace { + +char** HostEnvironment() { +#ifdef __APPLE__ + return *_NSGetEnviron(); +#else + return environ; +#endif +} + +const char* DefaultProgram() { +#ifdef __APPLE__ + return "/usr/bin/open"; +#else + return "xdg-open"; +#endif +} + +} + +HostBrowserLauncher::HostBrowserLauncher() : program(DefaultProgram()) {} + +HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::move(program)) {} + +bool HostBrowserLauncher::Launch(const std::string& url) { + if (url.empty() || url.front() == '-' || program.empty()) return false; + std::string name = program; + std::string address = url; + char* arguments[] = {name.data(), address.data(), nullptr}; + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) return false; + bool prepared = posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) == 0 && + posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0) == 0 && + posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) == 0; + pid_t child = 0; + const int error = prepared ? posix_spawnp(&child, name.c_str(), &actions, nullptr, arguments, HostEnvironment()) : -1; + posix_spawn_file_actions_destroy(&actions); + if (error != 0) return false; + std::thread([child] { + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + }).detach(); + return true; +} From 6197c3d718f7489fe3e27d875441584ac7798761 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:08:40 +0000 Subject: [PATCH 08/37] test(system-service): run the POSIX launcher against a recording opener Spawns a scratch script instead of xdg-open and checks that it receives the address as its single argument even when the address holds shell metacharacters, that nothing is executed or created by them, and that a missing program, an empty program, a leading dash and an empty address fail. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index bb8e4bd340aa..3f2b1ac5bc8f 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -1,5 +1,10 @@ #include "prx/libSceSystemService/HostUrl.hpp" #include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include #include #ifdef _WIN32 #include @@ -223,6 +228,64 @@ void CheckPolicy() { } } +#ifndef _WIN32 +std::filesystem::path MakeScratchDirectory() { + const auto directory = std::filesystem::temp_directory_path() / ("anyps5-open-url-" + std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())); + Require(std::filesystem::create_directories(directory)); + return directory; +} + +std::filesystem::path WriteOpener(const std::filesystem::path& directory) { + const auto opener = directory / "opener.sh"; + { + std::ofstream script(opener); + script << "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$0\" \"$1\" > \"" << directory.string() << "/record.tmp\"\nmv \"" << directory.string() << "/record.tmp\" \"" + << directory.string() << "/record\"\n"; + } + std::filesystem::permissions(opener, std::filesystem::perms::owner_all); + return opener; +} + +std::vector WaitForRecord(const std::filesystem::path& directory) { + for (int attempt = 0; attempt < 500; ++attempt) { + if (std::filesystem::exists(directory / "record")) { + std::ifstream record(directory / "record"); + std::vector lines; + for (std::string line; std::getline(record, line);) lines.push_back(line); + return lines; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + std::abort(); +} + +void CheckPosixLauncher() { + const auto directory = MakeScratchDirectory(); + const auto opener = WriteOpener(directory); + const std::string url = "https://example.com/$(touch_pwned);id&x=1#a*b"; + { + HostBrowserLauncher launcher(opener.string()); + Require(launcher.Launch(url)); + } + const auto lines = WaitForRecord(directory); + Require(lines.size() == 3); + Require(lines[0] == "1"); + Require(lines[1] == opener.string()); + Require(lines[2] == url); + Require(!std::filesystem::exists(directory / "touch_pwned")); + Require(!std::filesystem::exists(std::filesystem::current_path() / "touch_pwned")); + + HostBrowserLauncher missing((directory / "absent").string()); + Require(!missing.Launch("https://example.com/")); + HostBrowserLauncher empty{std::string()}; + Require(!empty.Launch("https://example.com/")); + HostBrowserLauncher guarded(opener.string()); + Require(!guarded.Launch("-https://example.com/")); + Require(!guarded.Launch("")); + std::filesystem::remove_all(directory); +} +#endif + } int main() { @@ -236,5 +299,8 @@ int main() { CheckLength(); CheckMode(); CheckPolicy(); +#ifndef _WIN32 + CheckPosixLauncher(); +#endif return 0; } From d42a26f199f09405708315cab505b2992d6cdf12 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:09:01 +0000 Subject: [PATCH 09/37] feat(system-service): launch URLs through ShellExecuteW on Windows The Windows backend converts the address to UTF-16 and hands it to ShellExecuteW with the open verb, which never goes through cmd.exe. Because ShellExecuteW also starts files and programs, the backend revalidates the address and refuses anything but a valid http or https URL. Only syntax-checked with MinGW; not run on Windows. --- .../prx/libSceSystemService/CMakeLists.txt | 5 ++++- .../HostUrlLauncherWindows.cpp | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index 92a99ad77bd1..a24c4dca8928 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -4,7 +4,10 @@ if(APPLE) endif() add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp) -if(NOT WIN32) +if(WIN32) + target_sources(libSceSystemService PRIVATE HostUrlLauncherWindows.cpp) + target_link_libraries(libSceSystemService PRIVATE shell32) +else() target_sources(libSceSystemService PRIVATE HostUrlLauncherPosix.cpp) endif() target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR}) diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp new file mode 100644 index 000000000000..287eb2a207ab --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherWindows.cpp @@ -0,0 +1,20 @@ +#include "prx/libSceSystemService/HostUrl.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include +#include +#include +#include + +HostBrowserLauncher::HostBrowserLauncher() = default; + +HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::move(program)) {} + +bool HostBrowserLauncher::Launch(const std::string& url) { + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return false; + const int length = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, url.c_str(), static_cast(url.size()), nullptr, 0); + if (length <= 0) return false; + std::wstring address(static_cast(length), L'\0'); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, url.c_str(), static_cast(url.size()), address.data(), length) != length) return false; + const auto result = reinterpret_cast(ShellExecuteW(nullptr, L"open", address.c_str(), nullptr, nullptr, SW_SHOWNORMAL)); + return result > 32; +} From 5f63c78b57f5885b4614c7ef19c6323d779e21ae Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:09:23 +0000 Subject: [PATCH 10/37] fix(system-service): make the POSIX launcher refuse anything but a valid URL The launcher only checked for a leading dash. It now applies the same validator as the policy, so a caller that bypasses OpenUrlOnHost cannot hand the opener a file path, another scheme or an address with credentials. --- core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp index d9097bd349bd..77d0b5f6f6dd 100644 --- a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp @@ -1,3 +1,4 @@ +#include "prx/libSceSystemService/HostUrl.hpp" #include "prx/libSceSystemService/HostUrlBackends.hpp" #include #include @@ -37,7 +38,7 @@ HostBrowserLauncher::HostBrowserLauncher() : program(DefaultProgram()) {} HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::move(program)) {} bool HostBrowserLauncher::Launch(const std::string& url) { - if (url.empty() || url.front() == '-' || program.empty()) return false; + if (ValidateHostUrl(url) != HostUrlVerdict::Ok || program.empty()) return false; std::string name = program; std::string address = url; char* arguments[] = {name.data(), address.data(), nullptr}; From 040915d2d636ee56bf98cc55d8bbe718927420c6 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:09:23 +0000 Subject: [PATCH 11/37] test(system-service): check the POSIX launcher refuses other schemes and credentials A file URL, an address with credentials and one with a space start nothing. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index 3f2b1ac5bc8f..a22754d03786 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -280,8 +280,14 @@ void CheckPosixLauncher() { HostBrowserLauncher empty{std::string()}; Require(!empty.Launch("https://example.com/")); HostBrowserLauncher guarded(opener.string()); + std::filesystem::remove(directory / "record"); Require(!guarded.Launch("-https://example.com/")); Require(!guarded.Launch("")); + Require(!guarded.Launch("file:///etc/passwd")); + Require(!guarded.Launch("https://user@example.com/")); + Require(!guarded.Launch("https://example.com/ x")); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + Require(!std::filesystem::exists(directory / "record")); std::filesystem::remove_all(directory); } #endif From d35772914dfb5e768020a4b7e7c4ee731287d359 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:09:53 +0000 Subject: [PATCH 12/37] feat(system-service): confirm a host URL launch with an SDL message box HostMessageBoxConfirmation shows the whole address (wrapped every 96 characters for display only) in a warning box with Cancel as the default for Return and Escape. SDL_ShowMessageBox needs no initialised video subsystem and no particular thread on Windows and X11, and the macOS implementation dispatches to the main queue. When SDL cannot show a box, for example with no display, the answer is no. libSceSystemService now links SDL2-static for this. --- .../prx/libSceSystemService/CMakeLists.txt | 6 +-- .../libSceSystemService/HostUrlBackends.hpp | 7 ++++ .../HostUrlConfirmation.cpp | 37 +++++++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index a24c4dca8928..6d1833208eed 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -3,15 +3,15 @@ if(APPLE) target_link_libraries(system_service_host_locale INTERFACE "-framework CoreFoundation") endif() -add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp) +add_library(libSceSystemService SHARED EXCLUDE_FROM_ALL Export.cpp PendingExports.cpp LoadExec.cpp HostUrlConfirmation.cpp) if(WIN32) target_sources(libSceSystemService PRIVATE HostUrlLauncherWindows.cpp) target_link_libraries(libSceSystemService PRIVATE shell32) else() target_sources(libSceSystemService PRIVATE HostUrlLauncherPosix.cpp) endif() -target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR}) -target_link_libraries(libSceSystemService PRIVATE libSceAgcDriver libSceUsbStorage system_service_host_locale) +target_include_directories(libSceSystemService PRIVATE ${LIBS_INCLUDE_DIR} ${ANYPS5_SDL2_INCLUDE_DIR}) +target_link_libraries(libSceSystemService PRIVATE libSceAgcDriver libSceUsbStorage system_service_host_locale SDL2-static) configure_windows_unwind(libSceSystemService) if(BUILD_TESTING) diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp index 5c688a0aac36..92408a91d32a 100644 --- a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -12,3 +12,10 @@ class HostBrowserLauncher final : public IUrlLauncher { private: std::string program; }; + +class HostMessageBoxConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override; +}; + +std::string HostConfirmationText(const std::string& url); diff --git a/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp b/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp new file mode 100644 index 000000000000..c9018da9fab7 --- /dev/null +++ b/core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp @@ -0,0 +1,37 @@ +#include "prx/libSceSystemService/HostUrlBackends.hpp" +#include + +namespace { + +constexpr std::size_t WRAP_WIDTH = 96; +constexpr int BUTTON_CANCEL = 0; +constexpr int BUTTON_OPEN = 1; + +} + +std::string HostConfirmationText(const std::string& url) { + std::string text = "A game asks to open this address in your default web browser:\n\n"; + for (std::size_t offset = 0; offset < url.size(); offset += WRAP_WIDTH) { + if (offset != 0) text += '\n'; + text.append(url, offset, WRAP_WIDTH); + } + text += "\n\nOpen it only if you trust it."; + return text; +} + +bool HostMessageBoxConfirmation::Confirm(const std::string& url) { + const std::string text = HostConfirmationText(url); + const SDL_MessageBoxButtonData buttons[] = { + {SDL_MESSAGEBOX_BUTTON_ESCAPEKEY_DEFAULT | SDL_MESSAGEBOX_BUTTON_RETURNKEY_DEFAULT, BUTTON_CANCEL, "Cancel"}, + {0, BUTTON_OPEN, "Open"}, + }; + SDL_MessageBoxData data{}; + data.flags = SDL_MESSAGEBOX_WARNING; + data.title = "AnyPS5: open a link?"; + data.message = text.c_str(); + data.numbuttons = 2; + data.buttons = buttons; + int pressed = BUTTON_CANCEL; + if (SDL_ShowMessageBox(&data, &pressed) != 0) return false; + return pressed == BUTTON_OPEN; +} From a9c86676c0c8d72c8816a64302f96f3e1043bf72 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:02 +0000 Subject: [PATCH 13/37] test(system-service): cover the confirmation text and the no-display answer The wrapped text keeps the whole address and no line over 96 characters. On Linux, with DISPLAY and WAYLAND_DISPLAY removed, the message box cannot be shown and the confirmation answers no without opening anything. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index a22754d03786..8370f6c33217 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -1,6 +1,7 @@ #include "prx/libSceSystemService/HostUrl.hpp" #include "prx/libSceSystemService/HostUrlPolicy.hpp" #include "prx/libSceSystemService/HostUrlBackends.hpp" +#include #include #include #include @@ -292,6 +293,37 @@ void CheckPosixLauncher() { } #endif +void CheckConfirmationText() { + const std::string url = "https://example.com/" + std::string(500, 'a') + "?q=1"; + const std::string text = HostConfirmationText(url); + std::size_t lineLength = 0; + std::size_t longest = 0; + for (const char value : text) { + if (value == '\n') { + longest = std::max(longest, lineLength); + lineLength = 0; + } else { + ++lineLength; + } + } + Require(longest <= 96); + std::string stripped; + for (const char value : text) + if (value != '\n') stripped += value; + Require(stripped.find(url) != std::string::npos); + Require(HostConfirmationText("https://example.com/").find("https://example.com/") != std::string::npos); +} + +#ifdef __linux__ +void CheckConfirmationWithoutDisplay() { + SetEnvironment("DISPLAY", nullptr); + SetEnvironment("WAYLAND_DISPLAY", nullptr); + SetEnvironment("SDL_VIDEODRIVER", nullptr); + HostMessageBoxConfirmation confirmation; + Require(!confirmation.Confirm("https://example.com/")); +} +#endif + } int main() { @@ -305,6 +337,10 @@ int main() { CheckLength(); CheckMode(); CheckPolicy(); + CheckConfirmationText(); +#ifdef __linux__ + CheckConfirmationWithoutDisplay(); +#endif #ifndef _WIN32 CheckPosixLauncher(); #endif From 5f5d8ece506407bc12b0b0ff7a2211327b56a1df Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:42 +0000 Subject: [PATCH 14/37] feat(system-service): open the URL of sceSystemServiceLaunchWebBrowser on the host After the sourced argument checks (null, 6015 bytes, http:, https: or psno:), the call now consults ANYPS5_OPEN_URLS. Unset, empty or never keep today's EOPNOTSUPP result. With ask or always, a URL that passes the host validator is confirmed and launched through the injectable backends and the call returns 0; a declined, rejected or failed launch still returns EOPNOTSUPP. The launch is serialised so two guest threads never stack confirmation boxes. An invalid ANYPS5_OPEN_URLS value throws an error naming the variable at the first call that gets past the argument checks. --- core/libs/prx/libSceSystemService/Export.cpp | 23 ++++++++++++++++++- .../libSceSystemService/HostUrlBackends.hpp | 2 ++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceSystemService/Export.cpp b/core/libs/prx/libSceSystemService/Export.cpp index dc56656c981f..ad13557cf62a 100644 --- a/core/libs/prx/libSceSystemService/Export.cpp +++ b/core/libs/prx/libSceSystemService/Export.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -32,11 +33,26 @@ #include "prx/libkernel/KernelErrors.hpp" #include "prx/libkernel/AppMetadata/include/AppMetadata.hpp" #include "prx/libSceSystemService/SystemService.hpp" +#include "prx/libSceSystemService/HostUrlBackends.hpp" #include "prx/libSceUserService/UserService.hpp" #include "prx/libSceAgcDriver/Execution/include/HostHdr.hpp" namespace { +HostBrowserLauncher g_defaultUrlLauncher; +HostMessageBoxConfirmation g_defaultUrlConfirmation; +std::atomic g_urlLauncher{&g_defaultUrlLauncher}; +std::atomic g_urlConfirmation{&g_defaultUrlConfirmation}; +std::mutex g_urlMutex; + +int OpenWebBrowserUrl(const char* uri) { + const OpenUrlMode mode = OpenUrlModeFromEnvironment("sceSystemServiceLaunchWebBrowser"); + if (mode == OpenUrlMode::Never) return SCE_KERNEL_ERROR_EOPNOTSUPP; + const std::lock_guard lock(g_urlMutex); + const OpenUrlOutcome outcome = OpenUrlOnHost(mode, uri, *g_urlLauncher.load(), *g_urlConfirmation.load()); + return outcome == OpenUrlOutcome::Launched ? SYSTEM_SERVICE_OK : SCE_KERNEL_ERROR_EOPNOTSUPP; +} + struct MeasuredParameter { int id; int value; @@ -623,7 +639,12 @@ int APS5_VABI sceSystemServiceLaunchWebBrowser(const char* uri) { while (length < 6016 && uri[length] != 0) ++length; if (length > 6015) return static_cast(0x80a10007); if (std::strncmp(uri, "http:", 5) != 0 && std::strncmp(uri, "https:", 6) != 0 && std::strncmp(uri, "psno:", 5) != 0) return static_cast(0x80a10008); - return SCE_KERNEL_ERROR_EOPNOTSUPP; + return OpenWebBrowserUrl(uri); +} + +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation) { + g_urlLauncher = launcher != nullptr ? launcher : &g_defaultUrlLauncher; + g_urlConfirmation = confirmation != nullptr ? confirmation : &g_defaultUrlConfirmation; } int APS5_VABI sceSystemServiceLaunchApp(const char* titleId, const char* const* arguments, const void* param) { diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp index 92408a91d32a..1e7e300d6c43 100644 --- a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -19,3 +19,5 @@ class HostMessageBoxConfirmation final : public IUrlConfirmation { }; std::string HostConfirmationText(const std::string& url); + +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation); From 86244b1c48e70e3bc394234530221a16c115f783 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:10:52 +0000 Subject: [PATCH 15/37] test(system-service): cover sceSystemServiceLaunchWebBrowser on the host With injected backends: unset, empty and never keep EOPNOTSUPP and touch neither backend; ask confirms before launching and a declined or failed launch returns EOPNOTSUPP; always launches without asking; invalid addresses and the sourced 0x80a10003, 0x80a10007 and 0x80a10008 results reach neither backend in any mode; a bogus variable throws naming it after the argument checks. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index 8370f6c33217..e3ff2cfef5e7 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -1,3 +1,4 @@ +#include "prx/libSceSystemService/include/SystemService.hpp" #include "prx/libSceSystemService/HostUrl.hpp" #include "prx/libSceSystemService/HostUrlPolicy.hpp" #include "prx/libSceSystemService/HostUrlBackends.hpp" @@ -324,6 +325,67 @@ void CheckConfirmationWithoutDisplay() { } #endif +void CheckLaunchWebBrowser() { + constexpr int unavailable = static_cast(0x8002002Du); + const std::string good = "https://example.com/store?id=1"; + RecordingLauncher launcher; + ScriptedConfirmation confirmation; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation); + + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + SetEnvironment("ANYPS5_OPEN_URLS", "never"); + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(launcher.launched.empty() && confirmation.asked.empty()); + + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == 0); + Require(confirmation.asked == std::vector{good} && launcher.launched == std::vector{good}); + confirmation.answer = false; + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(confirmation.asked.size() == 2 && launcher.launched.size() == 1); + confirmation.answer = true; + launcher.result = false; + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + launcher.result = true; + Require(launcher.launched.size() == 2); + + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + confirmation.answer = false; + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == 0); + Require(confirmation.asked.size() == 3 && launcher.launched.size() == 3); + confirmation.answer = true; + + const std::size_t asked = confirmation.asked.size(); + const std::size_t launched = launcher.launched.size(); + for (const char* refused : {"psno:", "http:", "https:", "http://", "https://user:pw@example.com/", "http://exa mple.com/", "https://example.com:0/", "https://example.com/%00"}) + Require(sceSystemServiceLaunchWebBrowser(refused) == unavailable); + Require(sceSystemServiceLaunchWebBrowser("") == static_cast(0x80a10008)); + Require(sceSystemServiceLaunchWebBrowser("HTTP://example.com/") == static_cast(0x80a10008)); + Require(sceSystemServiceLaunchWebBrowser("file:///etc/passwd") == static_cast(0x80a10008)); + Require(sceSystemServiceLaunchWebBrowser(nullptr) == static_cast(0x80a10003)); + const std::string tooLong = "https://example.com/" + std::string(5990, 'a'); + Require(sceSystemServiceLaunchWebBrowser(tooLong.c_str()) == unavailable); + const std::string overLimit = "https://example.com/" + std::string(6000, 'a') + std::string(100, 'a'); + Require(sceSystemServiceLaunchWebBrowser(overLimit.c_str()) == static_cast(0x80a10007)); + Require(confirmation.asked.size() == asked && launcher.launched.size() == launched); + + SetEnvironment("ANYPS5_OPEN_URLS", "bogus"); + bool threw = false; + try { + static_cast(sceSystemServiceLaunchWebBrowser(good.c_str())); + } catch (const std::runtime_error& error) { + threw = std::string(error.what()).find("ANYPS5_OPEN_URLS") != std::string::npos; + } + Require(threw); + Require(sceSystemServiceLaunchWebBrowser("") == static_cast(0x80a10008)); + Require(launcher.launched.size() == launched); + + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr); + Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); +} + } int main() { @@ -337,6 +399,7 @@ int main() { CheckLength(); CheckMode(); CheckPolicy(); + CheckLaunchWebBrowser(); CheckConfirmationText(); #ifdef __linux__ CheckConfirmationWithoutDisplay(); From 61d93e5c5556eaadfacdaf69034203a8cccacc77 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:21 +0000 Subject: [PATCH 16/37] docs(env): document ANYPS5_OPEN_URLS Adds the variable to the environment reference: never, ask or always, with unset and empty keeping the current failing result. --- docs/dev/ENVIRONMENT.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/ENVIRONMENT.md b/docs/dev/ENVIRONMENT.md index 1e13b52662cb..932df533dc3b 100644 --- a/docs/dev/ENVIRONMENT.md +++ b/docs/dev/ENVIRONMENT.md @@ -605,6 +605,7 @@ The code reads environment names through `getenv`, `IsEnvironmentSet`, `kibSetti | Name | Kind | Source | Default | Description | |------|------|--------|---------|-------------| | `ANYPS5_LANGUAGE` | integer | `core/libs/prx/libSceSystemService/SystemService.hpp` | 1 | console language number from 0 to 30 returned by sceSystemServiceParamGetInt, defaulting to English US; `auto` (any letter case) maps the host's locale to a console language once per process and falls back to English US with one stderr line; any other value throws | +| `ANYPS5_OPEN_URLS` | string | `core/libs/prx/libSceSystemService/HostUrlPolicy.hpp` | | `never`, `ask` or `always`: whether `sceSystemServiceLaunchWebBrowser` opens a valid http or https address in the host's default browser; unset, empty and `never` keep the call failing, `ask` shows the address in a confirmation box first, `always` opens it without asking; any other value throws | ### libSceUsbStorage From f51a530969f39b1d5edb4aa2a97f3be6d32780ae Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:41 +0000 Subject: [PATCH 17/37] docs(usage): describe ANYPS5_OPEN_URLS and the addresses it opens Lists the three modes, why always is discouraged, what a missing display does, the accepted address forms and the refusals, and how each host starts the browser. --- docs/user/USAGE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 99ec93356a8a..071454d3cfcb 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -383,6 +383,12 @@ On Linux the request goes to [Feral GameMode](https://github.com/FeralInteractiv Games ask whether an add-on (downloadable content) is installed with `sceNpEntitlementAccessGetAddcontEntitlementInfo` and list the installed ones with `sceNpEntitlementAccessGetAddcontEntitlementInfoList`. An add-on counts as installed only when its entitlement label is listed in `anyps5-entitlements.ini` beside the output executable, or in the file `ANYPS5_ENTITLEMENTS` names: one label per line, up to 16 characters, with `#` or `;` starting a comment. List the add-ons your copy includes. Every other label is reported as not installed, and the first query for each prints one line, such as `[entitlements] the title asked for the add-on EXAMPLEADDON0000, which anyps5-entitlements.ini does not list, so it is reported as not installed`. The line names the file as `anyps5-entitlements.ini`, or by the path `ANYPS5_ENTITLEMENTS` gives when it is set. Bytes of a label outside printable ASCII, and the backslash, are written as `\xNN`. After 64 such labels, one more line says that further ones are reported without a line. Some games wait at start-up for an add-on they look for instead of going on without it: a title (PPSA26344, version 02.002.000) stays on its loading spinner until the add-on label it asks for is listed. +### Opening links in the host browser + +A game that asks for a web page with `sceSystemServiceLaunchWebBrowser` gets a failure and nothing opens, unless `ANYPS5_OPEN_URLS` is set. `ANYPS5_OPEN_URLS=ask` shows the whole address in a message box with Cancel as the default button and opens it in the default browser only when you press Open; with no display to show the box, nothing opens. `ANYPS5_OPEN_URLS=always` opens it without asking, which is not recommended because the game picks the address. `never`, an empty value and an unset variable keep the failure. Any other value stops the game at its first browser request with an error that names the variable. + +Only `http://` and `https://` addresses of at most 2048 bytes open. The host refuses addresses with a user name or password, a space, a control character, a character outside ASCII (use punycode and percent escapes), a malformed or control-character percent escape, an empty host or a port outside 1 to 65535, and `psno:` addresses. The browser is started without a shell: `xdg-open` on Linux, `open` on macOS and `ShellExecuteW` on Windows. + ### Trophies A game unlocks a trophy by posting a `_UnlockTrophy` event with an int32 `_trophy_id` property to `libSceNpUniversalDataSystem`, or through the statistics rules described below. The unlock is stored in `_trophy/_.txt` in the directory the game runs in (the one that holds `app0/`), one ` ` line per trophy; the time is an RTC tick, microseconds since 0001-01-01 UTC. Delete the file before starting the game to lock its trophies again. Each new unlock prints one line, such as `[trophy] PPSA01556 unlocked trophy 12 for user 268435456`; unlocking a trophy again prints nothing. `sceNpTrophy2GetTrophyInfo`, `sceNpTrophy2GetTrophyInfoArray` and `sceNpTrophy2GetGameInfo` report the stored unlocks and their times to the game. When the game's trophy data is loaded (see below), a posted unlock of a trophy that the data does not have, of the platinum, or of a progressive trophy is not stored and prints the reason, such as `[trophy] ignored the posted unlock of trophy 0 for user 268435456: it is the platinum, which unlocks with the last trophy that names it`; the platinum unlocks with the last trophy that names it, and a progressive trophy through its statistic. Without trophy data every posted id of 0 or more is stored, and the platinum is not unlocked when the other trophies are. From d9cfcde4044bbf231690c634627a543e09ad112c Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:42 +0000 Subject: [PATCH 18/37] docs(debt): say the browser request only fails by default The silent-stub entry for sceSystemServiceLaunchWebBrowser now states the failure applies when ANYPS5_OPEN_URLS is unset, empty or never. --- docs/dev/debt/libSceSystemService.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index 636f2afbdb80..60887b455929 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -44,7 +44,7 @@ ### Silent stubs - [libSceSystemService](../../../core/libs/prx/libSceSystemService/Export.cpp) - `sceSystemServicePowerTick`, `sceSystemServiceReportAbnormalTermination`, `sceSystemServiceDisableMusicPlayer`, `sceSystemServiceReenableMusicPlayer`, `sceSystemServiceDisableMediaPlay` and `sceSystemServiceReenableMediaPlay` return success and do nothing: the host has no auto power-off timer to reset, no system crash reporter to notify, no system music player to pause or resume and no system media playback to disable. PPSA01521 calls `sceSystemServiceDisableMediaPlay` during start-up, and its wrappers for both pass no arguments -- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 launches no web browser, so the call fails and nothing is shown. RommPS PPSA76677 then tells the player to scan its QR code instead +- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - By default (`ANYPS5_OPEN_URLS` unset, empty or `never`) AnyPS5 launches no web browser, so the call fails and nothing is shown. RommPS PPSA76677 then tells the player to scan its QR code instead - [sceSystemServiceShowControllerSettings](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without showing the controller settings: AnyPS5 has no system UI to open. Cat Quest III (PPSA15065) imports it - [sceSystemServiceShowDisplaySafeAreaSettings](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without showing the display safe area settings dialog: AnyPS5 has no system UI to open. PPSA03332 imports it, and the no-argument signature is assumed: [Orbital](https://github.com/AlexAltea/orbital/blob/cc408367da6a6ebbf5ac1ac559fefeadc73b2831/tools/ida/db_types.json) has `sceSystemServiceShowControllerSettings` but not this call - [sceSystemServiceOpenChallengeActivity](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - returns success without opening the challenge activity: AnyPS5 has no system UI to open. Cat Quest III (PPSA15065) imports it From ecac5b62525a0d35d80c3553ce4b963ae27c579b Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:42 +0000 Subject: [PATCH 19/37] docs(debt): qualify the EOPNOTSUPP result of sceSystemServiceLaunchWebBrowser The result is the default one; ANYPS5_OPEN_URLS=ask or always changes it for addresses the host opens. --- docs/dev/debt/libSceSystemService.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index 60887b455929..52f19a0ebaf2 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -65,7 +65,7 @@ - [sceLncUtilGetAppLaunchedUser, sceSystemServiceGetAppLaunchedUser, sceShellCoreUtilGetUserFocus](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - for the running title's app ID they write the initial user, 0x10000000 (`USER_SERVICE_INITIAL_USER_ID`): `sceUserServiceGetInitialUser` reports that user as the one who started the title, and `sceUserServiceGetForegroundUser` reports it as the foreground user. A null user pointer returns 0x80940005 (0x80e30003 from `sceShellCoreUtilGetUserFocus`) before the app ID is looked at, as firmware 13.42's `libSceSystemService.sprx` does ([emulator run](https://github.com/boykopovar/AnyPS5/pull/10801#issuecomment-6403075212)). Another app ID throws, because the console's answer for it is unknown. The user is written as a 32-bit value, as the LncUtil process and status payloads entry says - [sceLncUtilIsAppSuspended, sceSystemServiceIsAppSuspended](../../../core/libs/prx/libSceSystemService/Export.cpp), [sceLncUtilIsSpeculativeLaunch](../../../core/libs/prx/libSceSystemService/PendingExports.cpp) (libSceSystemService) - for the running title's app ID they write false. The title that calls them is running, and AnyPS5 has no shell to suspend it. It was started from the host, not by `sceLncUtilLaunchApp`, so no `LncLaunchParam` asked for a speculative launch. A null output returns 0x80940005 before the app ID is looked at, as firmware 13.42's `libSceSystemService.sprx` does ([emulator run](https://github.com/boykopovar/AnyPS5/pull/10801#issuecomment-6403075212)). Another app ID throws, because the console's answer for it is unknown. Whether the console already reports a title as suspended while it handles its own suspend event is not known - [sceSystemServiceLaunchApp](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 runs one title and cannot start another, so every call throws without reading its arguments. The (title ID, null-terminated argv, launch parameters) signature follows [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/SystemService/SystemService.cs#L141-L148), whose `AppLauncher` Prospero Explorer (PPSA99109) and Prospero Multi Tools (PPSA99110) import it through and which passes a 0x20-byte launch context or null; the console's argument checks and errors are unknown -- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host still returns kernel `EOPNOTSUPP` for valid input; a missing browser service's native error is unresolved. +- [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host returns kernel `EOPNOTSUPP` for valid input unless `ANYPS5_OPEN_URLS` is `ask` or `always`; a missing browser service's native error is unresolved. - [sceSystemServiceParamGetString](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - the system name (parameter 6) is the fixed `PS5`, not the console's name. As in a payload process on a PS5 (firmware 13.60), the name is written NUL-padded to the whole buffer, and a buffer under 65 bytes, a null buffer or any other parameter returns `SYSTEM_SERVICE_ERROR_PARAMETER` without writing. Parameters 0 to 40, 100 to 110, 1000 to 1010, -1 and `INT_MAX` were tried there; the others are assumed to fail the same way. PPSA26344 at `0xA87935` passes parameter 6 with a 65-byte buffer - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - console language numbers 0 to 29 follow the PS4 SDK enum in [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) (21 Arabic). 30 (Ukrainian) is not in it and follows PPSA99505 ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/app/i18n.cpp#L29-L60)) and PPSA99002 ([ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight/blob/b708bbde3457bef1073a9ab6959957cb691e8e2f/include/i18n.hpp#L252-L309)), whose numbers agree with the enum. Not confirmed on a console, and neither title starts on `main` yet. Parameter 400 returns the same number as parameter 1: PPSA01325 asks for 400 first, asks for 1 when that fails, and uses either answer as an index into the same 31 languages; in the PS5 10.01 and 12.60 `libSceSystemService` modules parameter 400 reads the same registry value as parameter 1, without parameter 1's mapping of newer language numbers by SDK version, and returns 0x80a10003 when an imported check that is not identified returns 1; the 4.03 module has no parameter 400. Neither the mapping nor that error is modelled, and the parameter's name is not known. Without `ANYPS5_LANGUAGE` the language is English (United States); the date format and time format stay fixed - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - `ANYPS5_LANGUAGE=auto` takes the console language from the host locale with the table in [LanguageLocale.hpp](../../../core/libs/prx/libSceSystemService/LanguageLocale.hpp). The table is a host-side convenience: nothing was measured on a console about which language a person of a given host locale would choose, and what a title does with the number is unmeasured too. The language numbers and names are those of the entry above ([OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) for 0 to 29). From d3bcb3c33f3fd50111e3befbf3d5074745bda8ea Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:42 +0000 Subject: [PATCH 20/37] docs(debt): record the unmeasured success value and host limits of the browser launch The native result for a launched browser is unknown, and the host accepts fewer addresses than the console's own checks. --- docs/dev/debt/libSceSystemService.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index 52f19a0ebaf2..737efac58447 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -66,6 +66,7 @@ - [sceLncUtilIsAppSuspended, sceSystemServiceIsAppSuspended](../../../core/libs/prx/libSceSystemService/Export.cpp), [sceLncUtilIsSpeculativeLaunch](../../../core/libs/prx/libSceSystemService/PendingExports.cpp) (libSceSystemService) - for the running title's app ID they write false. The title that calls them is running, and AnyPS5 has no shell to suspend it. It was started from the host, not by `sceLncUtilLaunchApp`, so no `LncLaunchParam` asked for a speculative launch. A null output returns 0x80940005 before the app ID is looked at, as firmware 13.42's `libSceSystemService.sprx` does ([emulator run](https://github.com/boykopovar/AnyPS5/pull/10801#issuecomment-6403075212)). Another app ID throws, because the console's answer for it is unknown. Whether the console already reports a title as suspended while it handles its own suspend event is not known - [sceSystemServiceLaunchApp](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 runs one title and cannot start another, so every call throws without reading its arguments. The (title ID, null-terminated argv, launch parameters) signature follows [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/SystemService/SystemService.cs#L141-L148), whose `AppLauncher` Prospero Explorer (PPSA99109) and Prospero Multi Tools (PPSA99110) import it through and which passes a 0x20-byte launch context or null; the console's argument checks and errors are unknown - [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host returns kernel `EOPNOTSUPP` for valid input unless `ANYPS5_OPEN_URLS` is `ask` or `always`; a missing browser service's native error is unresolved. +- [sceSystemServiceLaunchWebBrowser host launch](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): with `ANYPS5_OPEN_URLS=ask` or `always` the call returns 0 once the host opener has started, and the native success value is not measured. Only `http://` and `https://` addresses of at most 2048 bytes without credentials, spaces, control characters, non-ASCII bytes or bad escapes open; `psno:` addresses and anything else the console's checks accept still return `EOPNOTSUPP`. These limits protect the host and are not console measurements. - [sceSystemServiceParamGetString](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - the system name (parameter 6) is the fixed `PS5`, not the console's name. As in a payload process on a PS5 (firmware 13.60), the name is written NUL-padded to the whole buffer, and a buffer under 65 bytes, a null buffer or any other parameter returns `SYSTEM_SERVICE_ERROR_PARAMETER` without writing. Parameters 0 to 40, 100 to 110, 1000 to 1010, -1 and `INT_MAX` were tried there; the others are assumed to fail the same way. PPSA26344 at `0xA87935` passes parameter 6 with a 65-byte buffer - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - console language numbers 0 to 29 follow the PS4 SDK enum in [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) (21 Arabic). 30 (Ukrainian) is not in it and follows PPSA99505 ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/app/i18n.cpp#L29-L60)) and PPSA99002 ([ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight/blob/b708bbde3457bef1073a9ab6959957cb691e8e2f/include/i18n.hpp#L252-L309)), whose numbers agree with the enum. Not confirmed on a console, and neither title starts on `main` yet. Parameter 400 returns the same number as parameter 1: PPSA01325 asks for 400 first, asks for 1 when that fails, and uses either answer as an index into the same 31 languages; in the PS5 10.01 and 12.60 `libSceSystemService` modules parameter 400 reads the same registry value as parameter 1, without parameter 1's mapping of newer language numbers by SDK version, and returns 0x80a10003 when an imported check that is not identified returns 1; the 4.03 module has no parameter 400. Neither the mapping nor that error is modelled, and the parameter's name is not known. Without `ANYPS5_LANGUAGE` the language is English (United States); the date format and time format stay fixed - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - `ANYPS5_LANGUAGE=auto` takes the console language from the host locale with the table in [LanguageLocale.hpp](../../../core/libs/prx/libSceSystemService/LanguageLocale.hpp). The table is a host-side convenience: nothing was measured on a console about which language a person of a given host locale would choose, and what a title does with the number is unmeasured too. The language numbers and names are those of the entry above ([OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) for 0 to 29). From ab35d08deb113ad0a4052ba4145e1270c57615a3 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:43 +0000 Subject: [PATCH 21/37] docs(debt): record which host launch backends are unverified Only the Linux opener is exercised; the Windows and macOS backends, the macOS main-queue dispatch and Wayland without Xwayland are reasoned from the SDL and platform documentation. --- docs/dev/debt/libSceSystemService.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index 737efac58447..abd7cee2bc09 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -67,6 +67,7 @@ - [sceSystemServiceLaunchApp](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - AnyPS5 runs one title and cannot start another, so every call throws without reading its arguments. The (title ID, null-terminated argv, launch parameters) signature follows [SharpProspero](https://github.com/SvenGDK/SharpProspero/blob/5dfdb4b0b166e83dc9ae275001afb695f12c7174/src/SharpProspero/Interop/SystemService/SystemService.cs#L141-L148), whose `AppLauncher` Prospero Explorer (PPSA99109) and Prospero Multi Tools (PPSA99110) import it through and which passes a 0x20-byte launch context or null; the console's argument checks and errors are unknown - [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host returns kernel `EOPNOTSUPP` for valid input unless `ANYPS5_OPEN_URLS` is `ask` or `always`; a missing browser service's native error is unresolved. - [sceSystemServiceLaunchWebBrowser host launch](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): with `ANYPS5_OPEN_URLS=ask` or `always` the call returns 0 once the host opener has started, and the native success value is not measured. Only `http://` and `https://` addresses of at most 2048 bytes without credentials, spaces, control characters, non-ASCII bytes or bad escapes open; `psno:` addresses and anything else the console's checks accept still return `EOPNOTSUPP`. These limits protect the host and are not console measurements. +- [sceSystemServiceLaunchWebBrowser host backends](../../../core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp): the call does not observe what the opener does after it starts, so an opener that fails later still reports success. The Windows `ShellExecuteW` backend, the macOS `open` backend and the confirmation box on Windows and macOS are not run, only the Linux backend is; the macOS box is shown from the calling guest thread by dispatching to the main queue, which blocks if the main thread never services it. A Wayland-only session with no Xwayland has no box, so `ask` opens nothing there. - [sceSystemServiceParamGetString](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - the system name (parameter 6) is the fixed `PS5`, not the console's name. As in a payload process on a PS5 (firmware 13.60), the name is written NUL-padded to the whole buffer, and a buffer under 65 bytes, a null buffer or any other parameter returns `SYSTEM_SERVICE_ERROR_PARAMETER` without writing. Parameters 0 to 40, 100 to 110, 1000 to 1010, -1 and `INT_MAX` were tried there; the others are assumed to fail the same way. PPSA26344 at `0xA87935` passes parameter 6 with a 65-byte buffer - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - console language numbers 0 to 29 follow the PS4 SDK enum in [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) (21 Arabic). 30 (Ukrainian) is not in it and follows PPSA99505 ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/app/i18n.cpp#L29-L60)) and PPSA99002 ([ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight/blob/b708bbde3457bef1073a9ab6959957cb691e8e2f/include/i18n.hpp#L252-L309)), whose numbers agree with the enum. Not confirmed on a console, and neither title starts on `main` yet. Parameter 400 returns the same number as parameter 1: PPSA01325 asks for 400 first, asks for 1 when that fails, and uses either answer as an index into the same 31 languages; in the PS5 10.01 and 12.60 `libSceSystemService` modules parameter 400 reads the same registry value as parameter 1, without parameter 1's mapping of newer language numbers by SDK version, and returns 0x80a10003 when an imported check that is not identified returns 1; the 4.03 module has no parameter 400. Neither the mapping nor that error is modelled, and the parameter's name is not known. Without `ANYPS5_LANGUAGE` the language is English (United States); the date format and time format stay fixed - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - `ANYPS5_LANGUAGE=auto` takes the console language from the host locale with the table in [LanguageLocale.hpp](../../../core/libs/prx/libSceSystemService/LanguageLocale.hpp). The table is a host-side convenience: nothing was measured on a console about which language a person of a given host locale would choose, and what a title does with the number is unmeasured too. The language numbers and names are those of the entry above ([OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) for 0 to 29). From b9cb9d81d585c3681d50f048bbcee1669a691c10 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:12:43 +0000 Subject: [PATCH 22/37] docs(debt): record that the web browser dialog's URL field is unsourced sceWebBrowserDialogOpen does not open anything on the host because no public source gives the parameter layout or the URL offset. --- docs/dev/debt/libSceWebBrowserDialog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev/debt/libSceWebBrowserDialog.md b/docs/dev/debt/libSceWebBrowserDialog.md index 4637724ad494..ea46b2bc88aa 100644 --- a/docs/dev/debt/libSceWebBrowserDialog.md +++ b/docs/dev/debt/libSceWebBrowserDialog.md @@ -3,7 +3,7 @@ ### Silent stubs - [sceWebBrowserDialogOpen, sceWebBrowserDialogOpenForPredeterminedContent](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) (libSceWebBrowserDialog) - with `ANYPS5_PSN_DIALOGS=show`, a successful open shows AnyPS5's generic notice that the system feature is not available; no PlayStation Network link is claimed, since no source ties this dialog to it, and the console's own display is not measured. -- [libSceWebBrowserDialog](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) - `sceWebBrowserDialogOpen` and `sceWebBrowserDialogOpenForPredeterminedContent` run the common-dialog state machine without displaying an interactive browser, and `sceWebBrowserDialogGetResult` reports that the user cancelled. Minecraft Bedrock (PPSA17221) imports `sceWebBrowserDialogOpenForPredeterminedContent` (#5744). The 424-byte allow-list parameter and its twenty optional address pointers are validated, but address filtering is not modelled without an interactive browser. Main open-parameter validation remains incomplete. +- [libSceWebBrowserDialog](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) - `sceWebBrowserDialogOpen` and `sceWebBrowserDialogOpenForPredeterminedContent` run the common-dialog state machine without displaying an interactive browser, and `sceWebBrowserDialogGetResult` reports that the user cancelled. Minecraft Bedrock (PPSA17221) imports `sceWebBrowserDialogOpenForPredeterminedContent` (#5744). The 424-byte allow-list parameter and its twenty optional address pointers are validated, but address filtering is not modelled without an interactive browser. Main open-parameter validation remains incomplete. The layout of the open parameter and the offset of its URL field have no public source (the OpenOrbis headers declare no parameters and shadPS4 takes none), so no URL from it is opened on the host. - [sceWebBrowserDialogSetCookie, sceWebBrowserDialogResetCookie](../../../core/libs/prx/libSceWebBrowserDialog/Export.cpp) (libSceWebBrowserDialog) - there is no browser cookie store. Both calls check their parameters in the same order and with the same codes as the PS5 13.60 module, then return 0 without storing or clearing anything. On the console they then forward the request to the browser service. While an opened dialog has not been updated to finished, closed or terminated, they return `0x80B80006` (the console's open-dialog flag). ### Unknown function info From 32356e7d92e0529856c15b5d069db140cbd3855e Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:09 +0000 Subject: [PATCH 23/37] feat(system-service): refuse a URL launch while another one is active OpenUrlGate runs OpenUrlOnHost behind a try-lock: a caller that arrives while a confirmation box or a launch is in progress gets Busy at once instead of queuing another dialog behind it. never still makes no backend call and an invalid address is rejected before the lock is touched. The gate takes an injectable clock for the limits that follow. Nothing calls it yet. --- .../prx/libSceSystemService/HostUrlPolicy.hpp | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp index d897bce1204d..a591037fdd34 100644 --- a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -1,6 +1,8 @@ #pragma once +#include #include +#include #include #include #include @@ -52,3 +54,52 @@ inline OpenUrlOutcome OpenUrlOnHost(OpenUrlMode mode, std::string_view url, IUrl if (mode == OpenUrlMode::Ask && !confirmation.Confirm(address)) return OpenUrlOutcome::Declined; return launcher.Launch(address) ? OpenUrlOutcome::Launched : OpenUrlOutcome::Failed; } + +class IUrlClock { +public: + virtual ~IUrlClock() = default; + virtual std::chrono::steady_clock::time_point Now() = 0; +}; + +class SteadyUrlClock final : public IUrlClock { +public: + std::chrono::steady_clock::time_point Now() override { return std::chrono::steady_clock::now(); } +}; + +enum class OpenUrlGateOutcome { + Disabled, + Rejected, + Busy, + Declined, + Failed, + Launched, +}; + +class OpenUrlGate { +public: + explicit OpenUrlGate(IUrlClock& clock) : clock(&clock) {} + + void Reset(IUrlClock& replacement) { + const std::lock_guard lock(state); + clock = &replacement; + } + + OpenUrlGateOutcome Run(OpenUrlMode mode, std::string_view url, IUrlLauncher& launcher, IUrlConfirmation& confirmation) { + if (mode == OpenUrlMode::Never) return OpenUrlGateOutcome::Disabled; + if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return OpenUrlGateOutcome::Rejected; + std::unique_lock lock(state, std::try_to_lock); + if (!lock.owns_lock()) return OpenUrlGateOutcome::Busy; + switch (OpenUrlOnHost(mode, url, launcher, confirmation)) { + case OpenUrlOutcome::Launched: return OpenUrlGateOutcome::Launched; + case OpenUrlOutcome::Declined: return OpenUrlGateOutcome::Declined; + case OpenUrlOutcome::Failed: return OpenUrlGateOutcome::Failed; + case OpenUrlOutcome::Rejected: return OpenUrlGateOutcome::Rejected; + case OpenUrlOutcome::Disabled: return OpenUrlGateOutcome::Disabled; + } + return OpenUrlGateOutcome::Failed; + } + +private: + std::mutex state; + IUrlClock* clock; +}; From d440ea43cc9b1159128aea26ebb77cb074eb10f4 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:18 +0000 Subject: [PATCH 24/37] test(system-service): cover the busy gate and never with counting fakes While a confirmation is blocked, other callers for the same and distinct addresses, in ask and always, get Busy without a second dialog or launch; a never call and an invalid address are answered without touching the lock; 1000 never calls make no backend call. --- .../prx/libSceSystemService/CMakeLists.txt | 9 ++ .../tests/GuestSystemServiceOpenUrlLimits.cpp | 109 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp diff --git a/core/libs/prx/libSceSystemService/CMakeLists.txt b/core/libs/prx/libSceSystemService/CMakeLists.txt index 6d1833208eed..a9428ec7a37d 100644 --- a/core/libs/prx/libSceSystemService/CMakeLists.txt +++ b/core/libs/prx/libSceSystemService/CMakeLists.txt @@ -112,6 +112,15 @@ if(BUILD_TESTING) set_tests_properties(guest_system_service_open_url PROPERTIES ENVIRONMENT_MODIFICATION "PATH=path_list_prepend:$") endif() + add_test_executable(guest_system_service_open_url_limits_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceOpenUrlLimits.cpp) + target_include_directories(guest_system_service_open_url_limits_tests PRIVATE ${LIBS_INCLUDE_DIR}) + target_link_libraries(guest_system_service_open_url_limits_tests PRIVATE libSceSystemService libc) + configure_windows_unwind(guest_system_service_open_url_limits_tests) + add_test(NAME guest_system_service_open_url_limits COMMAND guest_system_service_open_url_limits_tests) + if(WIN32) + set_tests_properties(guest_system_service_open_url_limits PROPERTIES ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:$") + endif() add_test_executable(guest_system_service_language_tests ${LIBS_INCLUDE_DIR}/tests/GuestSystemServiceLanguage.cpp) target_include_directories(guest_system_service_language_tests PRIVATE ${LIBS_INCLUDE_DIR}) target_link_libraries(guest_system_service_language_tests PRIVATE libSceSystemService libc) diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp new file mode 100644 index 000000000000..6c54e3d73ddb --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -0,0 +1,109 @@ +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +void Require(bool value) { if (!value) std::abort(); } + +class FakeClock final : public IUrlClock { +public: + std::chrono::steady_clock::time_point Now() override { return now; } + void Advance(std::chrono::seconds amount) { now += amount; } + +private: + std::chrono::steady_clock::time_point now{std::chrono::hours(1)}; +}; + +class CountingLauncher final : public IUrlLauncher { +public: + bool Launch(const std::string& url) override { + const std::lock_guard lock(guard); + launched.push_back(url); + return result; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return launched.size(); + } + std::mutex guard; + std::vector launched; + bool result = true; +}; + +class CountingConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override { + std::unique_lock lock(guard); + asked.push_back(url); + entered.notify_all(); + if (blocking) released.wait(lock, [this] { return !blocking; }); + return answer; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return asked.size(); + } + void WaitUntilAsked(std::size_t count) { + std::unique_lock lock(guard); + entered.wait(lock, [&] { return asked.size() >= count; }); + } + void Release() { + const std::lock_guard lock(guard); + blocking = false; + released.notify_all(); + } + std::mutex guard; + std::condition_variable entered; + std::condition_variable released; + std::vector asked; + bool answer = true; + bool blocking = false; +}; + +void CheckBusy() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.blocking = true; + OpenUrlGateOutcome first = OpenUrlGateOutcome::Failed; + std::thread active([&] { first = gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation); }); + confirmation.WaitUntilAsked(1); + + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/b", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(gate.Run(OpenUrlMode::Always, "https://example.com/c", launcher, confirmation) == OpenUrlGateOutcome::Busy); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + Require(gate.Run(OpenUrlMode::Never, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Disabled); + Require(gate.Run(OpenUrlMode::Ask, "psno://example.com", launcher, confirmation) == OpenUrlGateOutcome::Rejected); + + confirmation.Release(); + active.join(); + Require(first == OpenUrlGateOutcome::Launched); + Require(confirmation.Count() == 1 && launcher.Count() == 1); +} + +void CheckNever() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (int index = 0; index < 1000; ++index) + Require(gate.Run(OpenUrlMode::Never, "https://example.com/" + std::to_string(index), launcher, confirmation) == OpenUrlGateOutcome::Disabled); + Require(confirmation.Count() == 0 && launcher.Count() == 0); +} + +} + +int main() { + CheckBusy(); + CheckNever(); + return 0; +} From ed141e91a2cb313ae4bacec7535c737511584046 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:52:33 +0000 Subject: [PATCH 25/37] feat(system-service): cap host URL prompts and launches to three a minute Every confirmation shown in ask and every launch in always counts as an attempt in a sliding 60 second window kept by the shared gate. A fourth attempt inside the window returns Limited without a dialog or a launch, whatever the address, and the window empties as attempts age out of it by the injected clock. --- core/libs/prx/libSceSystemService/HostUrlPolicy.hpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp index a591037fdd34..9ec180764fed 100644 --- a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -1,7 +1,9 @@ #pragma once #include +#include #include +#include #include #include #include @@ -66,10 +68,14 @@ class SteadyUrlClock final : public IUrlClock { std::chrono::steady_clock::time_point Now() override { return std::chrono::steady_clock::now(); } }; +constexpr std::size_t OPEN_URL_MAX_ATTEMPTS = 3; +constexpr std::chrono::seconds OPEN_URL_WINDOW{60}; + enum class OpenUrlGateOutcome { Disabled, Rejected, Busy, + Limited, Declined, Failed, Launched, @@ -89,6 +95,10 @@ class OpenUrlGate { if (ValidateHostUrl(url) != HostUrlVerdict::Ok) return OpenUrlGateOutcome::Rejected; std::unique_lock lock(state, std::try_to_lock); if (!lock.owns_lock()) return OpenUrlGateOutcome::Busy; + const auto now = clock->Now(); + while (!attempts.empty() && now - attempts.front() >= OPEN_URL_WINDOW) attempts.pop_front(); + if (attempts.size() >= OPEN_URL_MAX_ATTEMPTS) return OpenUrlGateOutcome::Limited; + attempts.push_back(now); switch (OpenUrlOnHost(mode, url, launcher, confirmation)) { case OpenUrlOutcome::Launched: return OpenUrlGateOutcome::Launched; case OpenUrlOutcome::Declined: return OpenUrlGateOutcome::Declined; @@ -102,4 +112,5 @@ class OpenUrlGate { private: std::mutex state; IUrlClock* clock; + std::deque attempts; }; From 14314e13569d082113cca909b3ef5cd9ad64f748 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:06 +0000 Subject: [PATCH 26/37] test(system-service): cover the attempt cap, its recovery and what counts Three attempts pass in ask and in always, then 1000 same and distinct addresses are limited with no further dialog or launch; the window opens again exactly 60 seconds after its oldest attempt. Rejected addresses do not count, failed launches do. --- .../tests/GuestSystemServiceOpenUrlLimits.cpp | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp index 6c54e3d73ddb..925e34962b03 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -100,10 +100,56 @@ void CheckNever() { Require(confirmation.Count() == 0 && launcher.Count() == 0); } +void CheckRateCap() { + FakeClock clock; + for (const OpenUrlMode mode : {OpenUrlMode::Ask, OpenUrlMode::Always}) { + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) { + Require(gate.Run(mode, "https://example.com/" + std::to_string(index), launcher, confirmation) == OpenUrlGateOutcome::Launched); + clock.Advance(std::chrono::seconds(1)); + } + for (int index = 0; index < 1000; ++index) + Require(gate.Run(mode, index % 2 == 0 ? "https://example.com/same" : "https://example.org/" + std::to_string(index), launcher, confirmation) == + OpenUrlGateOutcome::Limited); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); + Require(confirmation.Count() == (mode == OpenUrlMode::Ask ? OPEN_URL_MAX_ATTEMPTS : 0)); + + clock.Advance(OPEN_URL_WINDOW - std::chrono::seconds(OPEN_URL_MAX_ATTEMPTS) - std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/late", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/recovered", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS + 1); + Require(gate.Run(mode, "https://example.com/again", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(std::chrono::seconds(1)); + Require(gate.Run(mode, "https://example.com/again", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(gate.Run(mode, "https://example.com/again2", launcher, confirmation) == OpenUrlGateOutcome::Limited); + clock.Advance(OPEN_URL_WINDOW); + } +} + +void CheckRejectedAndFailedCount() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + for (int index = 0; index < 50; ++index) + Require(gate.Run(OpenUrlMode::Always, "https://u:p@example.com/", launcher, confirmation) == OpenUrlGateOutcome::Rejected); + Require(launcher.Count() == 0); + launcher.result = false; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) + Require(gate.Run(OpenUrlMode::Always, "https://example.com/", launcher, confirmation) == OpenUrlGateOutcome::Failed); + Require(gate.Run(OpenUrlMode::Always, "https://example.com/", launcher, confirmation) == OpenUrlGateOutcome::Limited); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); +} + } int main() { CheckBusy(); CheckNever(); + CheckRateCap(); + CheckRejectedAndFailedCount(); return 0; } From 6c8661737af2d6c5b844314a133cbddcf74957aa Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:06 +0000 Subject: [PATCH 27/37] feat(system-service): stop asking for ten minutes after a Cancel A declined confirmation suppresses every further prompt and launch, for any address, until 600 seconds later by the injected clock; those calls return Suppressed at once and do not count against the per-minute cap. The pause is bounded, not for the whole session, so a user who cancelled once can still open a link they ask for later without restarting the game. --- core/libs/prx/libSceSystemService/HostUrlPolicy.hpp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp index 9ec180764fed..4ba602218ea3 100644 --- a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -70,12 +70,14 @@ class SteadyUrlClock final : public IUrlClock { constexpr std::size_t OPEN_URL_MAX_ATTEMPTS = 3; constexpr std::chrono::seconds OPEN_URL_WINDOW{60}; +constexpr std::chrono::seconds OPEN_URL_DECLINE_COOLDOWN{600}; enum class OpenUrlGateOutcome { Disabled, Rejected, Busy, Limited, + Suppressed, Declined, Failed, Launched, @@ -96,12 +98,16 @@ class OpenUrlGate { std::unique_lock lock(state, std::try_to_lock); if (!lock.owns_lock()) return OpenUrlGateOutcome::Busy; const auto now = clock->Now(); + if (suppressed && now < suppressedUntil) return OpenUrlGateOutcome::Suppressed; while (!attempts.empty() && now - attempts.front() >= OPEN_URL_WINDOW) attempts.pop_front(); if (attempts.size() >= OPEN_URL_MAX_ATTEMPTS) return OpenUrlGateOutcome::Limited; attempts.push_back(now); switch (OpenUrlOnHost(mode, url, launcher, confirmation)) { case OpenUrlOutcome::Launched: return OpenUrlGateOutcome::Launched; - case OpenUrlOutcome::Declined: return OpenUrlGateOutcome::Declined; + case OpenUrlOutcome::Declined: + suppressed = true; + suppressedUntil = now + OPEN_URL_DECLINE_COOLDOWN; + return OpenUrlGateOutcome::Declined; case OpenUrlOutcome::Failed: return OpenUrlGateOutcome::Failed; case OpenUrlOutcome::Rejected: return OpenUrlGateOutcome::Rejected; case OpenUrlOutcome::Disabled: return OpenUrlGateOutcome::Disabled; @@ -113,4 +119,6 @@ class OpenUrlGate { std::mutex state; IUrlClock* clock; std::deque attempts; + bool suppressed = false; + std::chrono::steady_clock::time_point suppressedUntil; }; From c0fb0de511435ba2d19d6aec76937c3b51f8ab6a Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:53:31 +0000 Subject: [PATCH 28/37] test(system-service): cover the cooldown after a Cancel After a decline every same or distinct address is suppressed in ask and always with no dialog or launch, until exactly 600 seconds later; repeated Cancel shows one box per interval; suppressed calls do not use up the per-minute attempts. --- .../tests/GuestSystemServiceOpenUrlLimits.cpp | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp index 925e34962b03..6de85e84483c 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -144,6 +144,60 @@ void CheckRejectedAndFailedCount() { Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); } +void CheckDeclineCooldown() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + for (int index = 0; index < 1000; ++index) { + Require(gate.Run(OpenUrlMode::Ask, index % 2 == 0 ? "https://example.com/a" : "https://example.net/" + std::to_string(index), launcher, confirmation) == + OpenUrlGateOutcome::Suppressed); + } + Require(gate.Run(OpenUrlMode::Always, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + + clock.Advance(OPEN_URL_DECLINE_COOLDOWN - std::chrono::seconds(1)); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + Require(confirmation.Count() == 1); + clock.Advance(std::chrono::seconds(1)); + confirmation.answer = true; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(confirmation.Count() == 2 && launcher.Count() == 1); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/b", launcher, confirmation) == OpenUrlGateOutcome::Launched); +} + +void CheckRepeatedCancel() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + for (int round = 0; round < 5; ++round) { + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN); + } + Require(confirmation.Count() == 5 && launcher.Count() == 0); +} + +void CheckSuppressedDoesNotUseAttempts() { + FakeClock clock; + OpenUrlGate gate(clock); + CountingLauncher launcher; + CountingConfirmation confirmation; + confirmation.answer = false; + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Declined); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN - std::chrono::seconds(1)); + for (int index = 0; index < 10; ++index) Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Suppressed); + clock.Advance(std::chrono::seconds(1)); + confirmation.answer = true; + for (std::size_t index = 0; index < OPEN_URL_MAX_ATTEMPTS; ++index) + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Launched); + Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Limited); +} + } int main() { @@ -151,5 +205,8 @@ int main() { CheckNever(); CheckRateCap(); CheckRejectedAndFailedCount(); + CheckDeclineCooldown(); + CheckRepeatedCancel(); + CheckSuppressedDoesNotUseAttempts(); return 0; } From 1b3df26920fe0d84299cd19efd594b7bb3a08b95 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:54:02 +0000 Subject: [PATCH 29/37] test(system-service): move the counting fakes into a shared test header The fake clock, launcher and confirmation move out of the limits test so the exported-call tests can reuse them. No assertion changes. --- .../tests/GuestSystemServiceOpenUrlFakes.hpp | 64 +++++++++++++++++++ .../tests/GuestSystemServiceOpenUrlLimits.cpp | 56 +--------------- 2 files changed, 65 insertions(+), 55 deletions(-) create mode 100644 core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp diff --git a/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp b/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp new file mode 100644 index 000000000000..c58c29330235 --- /dev/null +++ b/core/libs/tests/GuestSystemServiceOpenUrlFakes.hpp @@ -0,0 +1,64 @@ +#pragma once + +#include "prx/libSceSystemService/HostUrlPolicy.hpp" +#include +#include +#include +#include +#include +#include + +class FakeClock final : public IUrlClock { +public: + std::chrono::steady_clock::time_point Now() override { return now; } + void Advance(std::chrono::seconds amount) { now += amount; } + +private: + std::chrono::steady_clock::time_point now{std::chrono::hours(1)}; +}; + +class CountingLauncher final : public IUrlLauncher { +public: + bool Launch(const std::string& url) override { + const std::lock_guard lock(guard); + launched.push_back(url); + return result; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return launched.size(); + } + std::mutex guard; + std::vector launched; + bool result = true; +}; + +class CountingConfirmation final : public IUrlConfirmation { +public: + bool Confirm(const std::string& url) override { + std::unique_lock lock(guard); + asked.push_back(url); + entered.notify_all(); + if (blocking) released.wait(lock, [this] { return !blocking; }); + return answer; + } + std::size_t Count() { + const std::lock_guard lock(guard); + return asked.size(); + } + void WaitUntilAsked(std::size_t count) { + std::unique_lock lock(guard); + entered.wait(lock, [&] { return asked.size() >= count; }); + } + void Release() { + const std::lock_guard lock(guard); + blocking = false; + released.notify_all(); + } + std::mutex guard; + std::condition_variable entered; + std::condition_variable released; + std::vector asked; + bool answer = true; + bool blocking = false; +}; diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp index 6de85e84483c..a1092a961be7 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -1,3 +1,4 @@ +#include "GuestSystemServiceOpenUrlFakes.hpp" #include "prx/libSceSystemService/HostUrlPolicy.hpp" #include #include @@ -12,61 +13,6 @@ namespace { void Require(bool value) { if (!value) std::abort(); } -class FakeClock final : public IUrlClock { -public: - std::chrono::steady_clock::time_point Now() override { return now; } - void Advance(std::chrono::seconds amount) { now += amount; } - -private: - std::chrono::steady_clock::time_point now{std::chrono::hours(1)}; -}; - -class CountingLauncher final : public IUrlLauncher { -public: - bool Launch(const std::string& url) override { - const std::lock_guard lock(guard); - launched.push_back(url); - return result; - } - std::size_t Count() { - const std::lock_guard lock(guard); - return launched.size(); - } - std::mutex guard; - std::vector launched; - bool result = true; -}; - -class CountingConfirmation final : public IUrlConfirmation { -public: - bool Confirm(const std::string& url) override { - std::unique_lock lock(guard); - asked.push_back(url); - entered.notify_all(); - if (blocking) released.wait(lock, [this] { return !blocking; }); - return answer; - } - std::size_t Count() { - const std::lock_guard lock(guard); - return asked.size(); - } - void WaitUntilAsked(std::size_t count) { - std::unique_lock lock(guard); - entered.wait(lock, [&] { return asked.size() >= count; }); - } - void Release() { - const std::lock_guard lock(guard); - blocking = false; - released.notify_all(); - } - std::mutex guard; - std::condition_variable entered; - std::condition_variable released; - std::vector asked; - bool answer = true; - bool blocking = false; -}; - void CheckBusy() { FakeClock clock; OpenUrlGate gate(clock); From 4e7b6f395604d123607aa2a39995a8b9dd82baf6 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:54:17 +0000 Subject: [PATCH 30/37] feat(system-service): route sceSystemServiceLaunchWebBrowser through the shared gate All guest threads and all addresses now share one OpenUrlGate: callers that arrive during a dialog or launch, repeats after a Cancel and more than three attempts a minute return the existing EOPNOTSUPP without touching a backend. The mutex that queued callers is gone. The test hook takes the clock too and resets the gate; the existing exported-call test advances a fake clock between calls so its sequence is unaffected by the limits. --- core/libs/prx/libSceSystemService/Export.cpp | 11 ++--- .../libSceSystemService/HostUrlBackends.hpp | 2 +- .../prx/libSceSystemService/HostUrlPolicy.hpp | 2 + core/libs/tests/GuestSystemServiceOpenUrl.cpp | 43 +++++++++++-------- 4 files changed, 34 insertions(+), 24 deletions(-) diff --git a/core/libs/prx/libSceSystemService/Export.cpp b/core/libs/prx/libSceSystemService/Export.cpp index ad13557cf62a..897db4d93675 100644 --- a/core/libs/prx/libSceSystemService/Export.cpp +++ b/core/libs/prx/libSceSystemService/Export.cpp @@ -43,14 +43,14 @@ HostBrowserLauncher g_defaultUrlLauncher; HostMessageBoxConfirmation g_defaultUrlConfirmation; std::atomic g_urlLauncher{&g_defaultUrlLauncher}; std::atomic g_urlConfirmation{&g_defaultUrlConfirmation}; -std::mutex g_urlMutex; +SteadyUrlClock g_steadyUrlClock; +OpenUrlGate g_urlGate(g_steadyUrlClock); int OpenWebBrowserUrl(const char* uri) { const OpenUrlMode mode = OpenUrlModeFromEnvironment("sceSystemServiceLaunchWebBrowser"); if (mode == OpenUrlMode::Never) return SCE_KERNEL_ERROR_EOPNOTSUPP; - const std::lock_guard lock(g_urlMutex); - const OpenUrlOutcome outcome = OpenUrlOnHost(mode, uri, *g_urlLauncher.load(), *g_urlConfirmation.load()); - return outcome == OpenUrlOutcome::Launched ? SYSTEM_SERVICE_OK : SCE_KERNEL_ERROR_EOPNOTSUPP; + const OpenUrlGateOutcome outcome = g_urlGate.Run(mode, uri, *g_urlLauncher.load(), *g_urlConfirmation.load()); + return outcome == OpenUrlGateOutcome::Launched ? SYSTEM_SERVICE_OK : SCE_KERNEL_ERROR_EOPNOTSUPP; } struct MeasuredParameter { @@ -642,9 +642,10 @@ int APS5_VABI sceSystemServiceLaunchWebBrowser(const char* uri) { return OpenWebBrowserUrl(uri); } -extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation) { +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation, IUrlClock* clock) { g_urlLauncher = launcher != nullptr ? launcher : &g_defaultUrlLauncher; g_urlConfirmation = confirmation != nullptr ? confirmation : &g_defaultUrlConfirmation; + g_urlGate.Reset(clock != nullptr ? *clock : static_cast(g_steadyUrlClock)); } int APS5_VABI sceSystemServiceLaunchApp(const char* titleId, const char* const* arguments, const void* param) { diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp index 1e7e300d6c43..db15bb4361ec 100644 --- a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -20,4 +20,4 @@ class HostMessageBoxConfirmation final : public IUrlConfirmation { std::string HostConfirmationText(const std::string& url); -extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation); +extern "C" void SystemServiceSetUrlHandlers_nid_no_patch(IUrlLauncher* launcher, IUrlConfirmation* confirmation, IUrlClock* clock); diff --git a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp index 4ba602218ea3..5f8b58f209f1 100644 --- a/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlPolicy.hpp @@ -90,6 +90,8 @@ class OpenUrlGate { void Reset(IUrlClock& replacement) { const std::lock_guard lock(state); clock = &replacement; + attempts.clear(); + suppressed = false; } OpenUrlGateOutcome Run(OpenUrlMode mode, std::string_view url, IUrlLauncher& launcher, IUrlConfirmation& confirmation) { diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index e3ff2cfef5e7..b3bb705fcb56 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -1,3 +1,4 @@ +#include "GuestSystemServiceOpenUrlFakes.hpp" #include "prx/libSceSystemService/include/SystemService.hpp" #include "prx/libSceSystemService/HostUrl.hpp" #include "prx/libSceSystemService/HostUrlPolicy.hpp" @@ -330,60 +331,66 @@ void CheckLaunchWebBrowser() { const std::string good = "https://example.com/store?id=1"; RecordingLauncher launcher; ScriptedConfirmation confirmation; - SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation); + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + const auto call = [&](const char* uri) { + const int result = sceSystemServiceLaunchWebBrowser(uri); + clock.Advance(std::chrono::seconds(700)); + return result; + }; SetEnvironment("ANYPS5_OPEN_URLS", nullptr); - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(call(good.c_str()) == unavailable); SetEnvironment("ANYPS5_OPEN_URLS", "never"); - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(call(good.c_str()) == unavailable); Require(launcher.launched.empty() && confirmation.asked.empty()); SetEnvironment("ANYPS5_OPEN_URLS", "ask"); - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == 0); + Require(call(good.c_str()) == 0); Require(confirmation.asked == std::vector{good} && launcher.launched == std::vector{good}); confirmation.answer = false; - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(call(good.c_str()) == unavailable); Require(confirmation.asked.size() == 2 && launcher.launched.size() == 1); confirmation.answer = true; launcher.result = false; - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + Require(call(good.c_str()) == unavailable); launcher.result = true; Require(launcher.launched.size() == 2); SetEnvironment("ANYPS5_OPEN_URLS", "always"); confirmation.answer = false; - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == 0); + Require(call(good.c_str()) == 0); Require(confirmation.asked.size() == 3 && launcher.launched.size() == 3); confirmation.answer = true; const std::size_t asked = confirmation.asked.size(); const std::size_t launched = launcher.launched.size(); for (const char* refused : {"psno:", "http:", "https:", "http://", "https://user:pw@example.com/", "http://exa mple.com/", "https://example.com:0/", "https://example.com/%00"}) - Require(sceSystemServiceLaunchWebBrowser(refused) == unavailable); - Require(sceSystemServiceLaunchWebBrowser("") == static_cast(0x80a10008)); - Require(sceSystemServiceLaunchWebBrowser("HTTP://example.com/") == static_cast(0x80a10008)); - Require(sceSystemServiceLaunchWebBrowser("file:///etc/passwd") == static_cast(0x80a10008)); - Require(sceSystemServiceLaunchWebBrowser(nullptr) == static_cast(0x80a10003)); + Require(call(refused) == unavailable); + Require(call("") == static_cast(0x80a10008)); + Require(call("HTTP://example.com/") == static_cast(0x80a10008)); + Require(call("file:///etc/passwd") == static_cast(0x80a10008)); + Require(call(nullptr) == static_cast(0x80a10003)); const std::string tooLong = "https://example.com/" + std::string(5990, 'a'); - Require(sceSystemServiceLaunchWebBrowser(tooLong.c_str()) == unavailable); + Require(call(tooLong.c_str()) == unavailable); const std::string overLimit = "https://example.com/" + std::string(6000, 'a') + std::string(100, 'a'); - Require(sceSystemServiceLaunchWebBrowser(overLimit.c_str()) == static_cast(0x80a10007)); + Require(call(overLimit.c_str()) == static_cast(0x80a10007)); Require(confirmation.asked.size() == asked && launcher.launched.size() == launched); SetEnvironment("ANYPS5_OPEN_URLS", "bogus"); bool threw = false; try { - static_cast(sceSystemServiceLaunchWebBrowser(good.c_str())); + static_cast(call(good.c_str())); } catch (const std::runtime_error& error) { threw = std::string(error.what()).find("ANYPS5_OPEN_URLS") != std::string::npos; } Require(threw); - Require(sceSystemServiceLaunchWebBrowser("") == static_cast(0x80a10008)); + Require(call("") == static_cast(0x80a10008)); Require(launcher.launched.size() == launched); SetEnvironment("ANYPS5_OPEN_URLS", nullptr); - SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr); - Require(sceSystemServiceLaunchWebBrowser(good.c_str()) == unavailable); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); + Require(call(good.c_str()) == unavailable); } } From f2b93c82308139df17e8b72d3ce4874588443d58 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:54:30 +0000 Subject: [PATCH 31/37] test(system-service): cover the limits through the exported call With counting fakes and a fake clock: repeated Cancel on the same and distinct addresses shows one box until the cooldown ends; more than three launches a minute are refused and resume after the window; 1000 never calls reach no backend; sixteen concurrent callers during an open confirmation get EOPNOTSUPP and add no second box. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index b3bb705fcb56..c456a9fbb875 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -4,6 +4,7 @@ #include "prx/libSceSystemService/HostUrlPolicy.hpp" #include "prx/libSceSystemService/HostUrlBackends.hpp" #include +#include #include #include #include @@ -393,6 +394,72 @@ void CheckLaunchWebBrowser() { Require(call(good.c_str()) == unavailable); } +void CheckExportedLimits() { + constexpr int unavailable = static_cast(0x8002002Du); + CountingLauncher launcher; + CountingConfirmation confirmation; + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + + confirmation.answer = false; + Require(sceSystemServiceLaunchWebBrowser("https://example.com/a") == unavailable); + for (int index = 0; index < 200; ++index) + Require(sceSystemServiceLaunchWebBrowser(index % 2 == 0 ? "https://example.com/a" : "https://example.org/b") == unavailable); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + clock.Advance(OPEN_URL_DECLINE_COOLDOWN); + confirmation.answer = true; + Require(sceSystemServiceLaunchWebBrowser("https://example.com/a") == 0); + Require(confirmation.Count() == 2 && launcher.Count() == 1); + + SetEnvironment("ANYPS5_OPEN_URLS", "always"); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/b") == 0); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/c") == 0); + for (int index = 0; index < 200; ++index) Require(sceSystemServiceLaunchWebBrowser("https://example.com/d") == unavailable); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS); + clock.Advance(OPEN_URL_WINDOW); + Require(sceSystemServiceLaunchWebBrowser("https://example.com/e") == 0); + Require(launcher.Count() == OPEN_URL_MAX_ATTEMPTS + 1); + + SetEnvironment("ANYPS5_OPEN_URLS", "never"); + const std::size_t asked = confirmation.Count(); + const std::size_t launched = launcher.Count(); + for (int index = 0; index < 1000; ++index) Require(sceSystemServiceLaunchWebBrowser("https://example.com/f") == unavailable); + Require(confirmation.Count() == asked && launcher.Count() == launched); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); +} + +void CheckExportedConcurrentCallers() { + constexpr int unavailable = static_cast(0x8002002Du); + CountingLauncher launcher; + CountingConfirmation confirmation; + FakeClock clock; + SystemServiceSetUrlHandlers_nid_no_patch(&launcher, &confirmation, &clock); + SetEnvironment("ANYPS5_OPEN_URLS", "ask"); + confirmation.blocking = true; + + std::atomic first{-1}; + std::thread active([&] { first = sceSystemServiceLaunchWebBrowser("https://example.com/a"); }); + confirmation.WaitUntilAsked(1); + std::vector callers; + std::atomic unavailableCount{0}; + for (int index = 0; index < 16; ++index) + callers.emplace_back([&, index] { + const std::string url = "https://example.com/" + std::to_string(index % 3); + if (sceSystemServiceLaunchWebBrowser(url.c_str()) == unavailable) ++unavailableCount; + }); + for (std::thread& caller : callers) caller.join(); + Require(unavailableCount == 16); + Require(confirmation.Count() == 1 && launcher.Count() == 0); + confirmation.Release(); + active.join(); + Require(first == 0); + Require(confirmation.Count() == 1 && launcher.Count() == 1); + SetEnvironment("ANYPS5_OPEN_URLS", nullptr); + SystemServiceSetUrlHandlers_nid_no_patch(nullptr, nullptr, nullptr); +} + } int main() { @@ -407,6 +474,8 @@ int main() { CheckMode(); CheckPolicy(); CheckLaunchWebBrowser(); + CheckExportedLimits(); + CheckExportedConcurrentCallers(); CheckConfirmationText(); #ifdef __linux__ CheckConfirmationWithoutDisplay(); From 20b6d5286e8a5ad9f4866fa28bfffed029daaec7 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:54:46 +0000 Subject: [PATCH 32/37] feat(system-service): bound the POSIX openers running at once to two Each accepted launch left a child and a detached wait thread for as long as the opener lived. The launcher now counts running openers, shared with the wait threads so it survives the launcher, and refuses a third until one exits. The gate's three a minute still apply on top. --- .../prx/libSceSystemService/HostUrlBackends.hpp | 5 +++++ .../HostUrlLauncherPosix.cpp | 17 ++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp index db15bb4361ec..83b5f4abcf03 100644 --- a/core/libs/prx/libSceSystemService/HostUrlBackends.hpp +++ b/core/libs/prx/libSceSystemService/HostUrlBackends.hpp @@ -1,8 +1,12 @@ #pragma once +#include +#include #include #include "prx/libSceSystemService/HostUrlPolicy.hpp" +constexpr int HOST_BROWSER_MAX_OPENERS = 2; + class HostBrowserLauncher final : public IUrlLauncher { public: HostBrowserLauncher(); @@ -11,6 +15,7 @@ class HostBrowserLauncher final : public IUrlLauncher { private: std::string program; + std::shared_ptr> running = std::make_shared>(0); }; class HostMessageBoxConfirmation final : public IUrlConfirmation { diff --git a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp index 77d0b5f6f6dd..07de8436a565 100644 --- a/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp +++ b/core/libs/prx/libSceSystemService/HostUrlLauncherPosix.cpp @@ -39,22 +39,33 @@ HostBrowserLauncher::HostBrowserLauncher(std::string program) : program(std::mov bool HostBrowserLauncher::Launch(const std::string& url) { if (ValidateHostUrl(url) != HostUrlVerdict::Ok || program.empty()) return false; + if (running->fetch_add(1) >= HOST_BROWSER_MAX_OPENERS) { + --*running; + return false; + } std::string name = program; std::string address = url; char* arguments[] = {name.data(), address.data(), nullptr}; posix_spawn_file_actions_t actions; - if (posix_spawn_file_actions_init(&actions) != 0) return false; + if (posix_spawn_file_actions_init(&actions) != 0) { + --*running; + return false; + } bool prepared = posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) == 0 && posix_spawn_file_actions_addopen(&actions, 1, "/dev/null", O_WRONLY, 0) == 0 && posix_spawn_file_actions_addopen(&actions, 2, "/dev/null", O_WRONLY, 0) == 0; pid_t child = 0; const int error = prepared ? posix_spawnp(&child, name.c_str(), &actions, nullptr, arguments, HostEnvironment()) : -1; posix_spawn_file_actions_destroy(&actions); - if (error != 0) return false; - std::thread([child] { + if (error != 0) { + --*running; + return false; + } + std::thread([child, count = running] { int status = 0; while (waitpid(child, &status, 0) < 0 && errno == EINTR) { } + --*count; }).detach(); return true; } From 4e969b9d9cb10683de1f1e8962147e9e47c318af Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:55:31 +0000 Subject: [PATCH 33/37] test(system-service): cover the bound on running POSIX openers A blocking opener script holds two launches open: a third and twenty more are refused and start nothing; once the first two exit a launch succeeds again. --- core/libs/tests/GuestSystemServiceOpenUrl.cpp | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrl.cpp b/core/libs/tests/GuestSystemServiceOpenUrl.cpp index c456a9fbb875..cac874f256b6 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrl.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrl.cpp @@ -294,6 +294,44 @@ void CheckPosixLauncher() { Require(!std::filesystem::exists(directory / "record")); std::filesystem::remove_all(directory); } + +std::size_t CountStarted(const std::filesystem::path& directory) { + std::size_t count = 0; + for (const auto& entry : std::filesystem::directory_iterator(directory)) + if (entry.path().filename().string().rfind("started.", 0) == 0) ++count; + return count; +} + +void CheckPosixOutstandingBound() { + const auto directory = MakeScratchDirectory(); + const auto opener = directory / "blocking.sh"; + { + std::ofstream script(opener); + script << "#!/bin/sh\ntouch \"" << directory.string() << "/started.$$\"\nwhile [ ! -e \"" << directory.string() + << "/release\" ]; do sleep 0.05; done\n"; + } + std::filesystem::permissions(opener, std::filesystem::perms::owner_all); + HostBrowserLauncher launcher(opener.string()); + Require(HOST_BROWSER_MAX_OPENERS == 2); + Require(launcher.Launch("https://example.com/1")); + Require(launcher.Launch("https://example.com/2")); + for (int index = 0; index < 20; ++index) Require(!launcher.Launch("https://example.com/3")); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + Require(CountStarted(directory) == 2); + + { std::ofstream release(directory / "release"); } + bool recovered = false; + for (int attempt = 0; attempt < 500 && !recovered; ++attempt) { + recovered = launcher.Launch("https://example.com/4"); + if (!recovered) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + Require(recovered); + for (int attempt = 0; attempt < 500 && CountStarted(directory) < 3; ++attempt) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + Require(CountStarted(directory) == 3); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + std::filesystem::remove_all(directory); +} + #endif void CheckConfirmationText() { @@ -482,6 +520,7 @@ int main() { #endif #ifndef _WIN32 CheckPosixLauncher(); + CheckPosixOutstandingBound(); #endif return 0; } From fa4c0fe6c4b4a035832e4111ee8d77a72ef92a04 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:55:32 +0000 Subject: [PATCH 34/37] docs(usage): say that always opens localhost and private-network addresses ask shows the address before anything opens; always opens whatever http or https host the game names, including localhost and a router's page. --- docs/user/USAGE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 071454d3cfcb..55261d389375 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -385,7 +385,7 @@ Games ask whether an add-on (downloadable content) is installed with `sceNpEntit ### Opening links in the host browser -A game that asks for a web page with `sceSystemServiceLaunchWebBrowser` gets a failure and nothing opens, unless `ANYPS5_OPEN_URLS` is set. `ANYPS5_OPEN_URLS=ask` shows the whole address in a message box with Cancel as the default button and opens it in the default browser only when you press Open; with no display to show the box, nothing opens. `ANYPS5_OPEN_URLS=always` opens it without asking, which is not recommended because the game picks the address. `never`, an empty value and an unset variable keep the failure. Any other value stops the game at its first browser request with an error that names the variable. +A game that asks for a web page with `sceSystemServiceLaunchWebBrowser` gets a failure and nothing opens, unless `ANYPS5_OPEN_URLS` is set. `ANYPS5_OPEN_URLS=ask` shows the whole address in a message box with Cancel as the default button and opens it in the default browser only when you press Open; with no display to show the box, nothing opens. `ANYPS5_OPEN_URLS=always` opens it without asking, which is not recommended because the game picks the address. Unlike `ask`, where you see the address first, `always` opens any `http` or `https` host the game names, including `localhost` and private-network addresses such as a router's web page. `never`, an empty value and an unset variable keep the failure. Any other value stops the game at its first browser request with an error that names the variable. Only `http://` and `https://` addresses of at most 2048 bytes open. The host refuses addresses with a user name or password, a space, a control character, a character outside ASCII (use punycode and percent escapes), a malformed or control-character percent escape, an empty host or a port outside 1 to 65535, and `psno:` addresses. The browser is started without a shell: `xdg-open` on Linux, `open` on macOS and `ShellExecuteW` on Windows. From 0dfdccd27f667d8ba604de9740402849dedc0404 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:55:32 +0000 Subject: [PATCH 35/37] docs(usage): describe the browser request limits Busy callers are refused, three attempts a minute, and ten minutes of silence after Cancel; refused requests fail like the default. --- docs/user/USAGE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/user/USAGE.md b/docs/user/USAGE.md index 55261d389375..1697c31d0993 100644 --- a/docs/user/USAGE.md +++ b/docs/user/USAGE.md @@ -387,6 +387,8 @@ Games ask whether an add-on (downloadable content) is installed with `sceNpEntit A game that asks for a web page with `sceSystemServiceLaunchWebBrowser` gets a failure and nothing opens, unless `ANYPS5_OPEN_URLS` is set. `ANYPS5_OPEN_URLS=ask` shows the whole address in a message box with Cancel as the default button and opens it in the default browser only when you press Open; with no display to show the box, nothing opens. `ANYPS5_OPEN_URLS=always` opens it without asking, which is not recommended because the game picks the address. Unlike `ask`, where you see the address first, `always` opens any `http` or `https` host the game names, including `localhost` and private-network addresses such as a router's web page. `never`, an empty value and an unset variable keep the failure. Any other value stops the game at its first browser request with an error that names the variable. +Browser requests are limited for all addresses and all game threads together: a request that arrives while a box or a launch is still active is refused at once, at most three boxes or launches happen in any 60 seconds, and after you press Cancel no further box or launch happens for 10 minutes. Refused requests fail like the default. + Only `http://` and `https://` addresses of at most 2048 bytes open. The host refuses addresses with a user name or password, a space, a control character, a character outside ASCII (use punycode and percent escapes), a malformed or control-character percent escape, an empty host or a port outside 1 to 65535, and `psno:` addresses. The browser is started without a shell: `xdg-open` on Linux, `open` on macOS and `ShellExecuteW` on Windows. ### Trophies From 924a338f3665a8496e6941ce4a0af19912de0971 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 13:55:32 +0000 Subject: [PATCH 36/37] docs(debt): record that the browser request limits are host policy The busy refusal, the attempt cap, the cooldown after Cancel and the opener bound are not measured on the console. --- docs/dev/debt/libSceSystemService.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev/debt/libSceSystemService.md b/docs/dev/debt/libSceSystemService.md index abd7cee2bc09..7506ac3316df 100644 --- a/docs/dev/debt/libSceSystemService.md +++ b/docs/dev/debt/libSceSystemService.md @@ -68,6 +68,7 @@ - [sceSystemServiceLaunchWebBrowser](../../../core/libs/prx/libSceSystemService/Export.cpp): the native export takes one URI argument. Null input, the 6015-byte length limit and supported scheme checks are recovered. The host returns kernel `EOPNOTSUPP` for valid input unless `ANYPS5_OPEN_URLS` is `ask` or `always`; a missing browser service's native error is unresolved. - [sceSystemServiceLaunchWebBrowser host launch](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): with `ANYPS5_OPEN_URLS=ask` or `always` the call returns 0 once the host opener has started, and the native success value is not measured. Only `http://` and `https://` addresses of at most 2048 bytes without credentials, spaces, control characters, non-ASCII bytes or bad escapes open; `psno:` addresses and anything else the console's checks accept still return `EOPNOTSUPP`. These limits protect the host and are not console measurements. - [sceSystemServiceLaunchWebBrowser host backends](../../../core/libs/prx/libSceSystemService/HostUrlConfirmation.cpp): the call does not observe what the opener does after it starts, so an opener that fails later still reports success. The Windows `ShellExecuteW` backend, the macOS `open` backend and the confirmation box on Windows and macOS are not run, only the Linux backend is; the macOS box is shown from the calling guest thread by dispatching to the main queue, which blocks if the main thread never services it. A Wayland-only session with no Xwayland has no box, so `ask` opens nothing there. +- [sceSystemServiceLaunchWebBrowser host limits](../../../core/libs/prx/libSceSystemService/HostUrlPolicy.hpp): the host refuses a request while another is active, allows three prompts or launches in 60 seconds, pauses ten minutes after a Cancel and runs at most two POSIX openers at once. These numbers protect the host from a title that retries in a loop; they are not console measurements, and the console's own behaviour for repeated calls is not measured. - [sceSystemServiceParamGetString](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - the system name (parameter 6) is the fixed `PS5`, not the console's name. As in a payload process on a PS5 (firmware 13.60), the name is written NUL-padded to the whole buffer, and a buffer under 65 bytes, a null buffer or any other parameter returns `SYSTEM_SERVICE_ERROR_PARAMETER` without writing. Parameters 0 to 40, 100 to 110, 1000 to 1010, -1 and `INT_MAX` were tried there; the others are assumed to fail the same way. PPSA26344 at `0xA87935` passes parameter 6 with a 65-byte buffer - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - console language numbers 0 to 29 follow the PS4 SDK enum in [OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) (21 Arabic). 30 (Ukrainian) is not in it and follows PPSA99505 ([Jelly5](https://github.com/02dnot/Jelly5/blob/552b866d89b2a58b27f7f7b0484dc743ce235526/app/src/app/i18n.cpp#L29-L60)) and PPSA99002 ([ProsperoLight](https://github.com/blackbearreloaded/ProsperoLight/blob/b708bbde3457bef1073a9ab6959957cb691e8e2f/include/i18n.hpp#L252-L309)), whose numbers agree with the enum. Not confirmed on a console, and neither title starts on `main` yet. Parameter 400 returns the same number as parameter 1: PPSA01325 asks for 400 first, asks for 1 when that fails, and uses either answer as an index into the same 31 languages; in the PS5 10.01 and 12.60 `libSceSystemService` modules parameter 400 reads the same registry value as parameter 1, without parameter 1's mapping of newer language numbers by SDK version, and returns 0x80a10003 when an imported check that is not identified returns 1; the 4.03 module has no parameter 400. Neither the mapping nor that error is modelled, and the parameter's name is not known. Without `ANYPS5_LANGUAGE` the language is English (United States); the date format and time format stay fixed - [sceSystemServiceParamGetInt](../../../core/libs/prx/libSceSystemService/Export.cpp) (libSceSystemService) - `ANYPS5_LANGUAGE=auto` takes the console language from the host locale with the table in [LanguageLocale.hpp](../../../core/libs/prx/libSceSystemService/LanguageLocale.hpp). The table is a host-side convenience: nothing was measured on a console about which language a person of a given host locale would choose, and what a title does with the number is unmeasured too. The language numbers and names are those of the entry above ([OpenOrbis](https://github.com/OpenOrbis/OpenOrbis-PS4-Toolchain/blob/06fcf7fd108fa080035cdd3259ed22eb07e0508c/include/orbis/_types/sys_service.h#L54-L83) for 0 to 29). From 737ac7199d6fa4048d32016835442d5f421cd434 Mon Sep 17 00:00:00 2001 From: MarcZX10 <67475268+MarcZX10@users.noreply.github.com> Date: Thu, 29 Oct 2026 14:14:55 +0000 Subject: [PATCH 37/37] test(system-service): pin the documented limit values The usage text promises three attempts, 60 seconds and 10 minutes; the limits test derived its sequences from the constants, so changing a constant moved test and code together. The values are now asserted literally. --- core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp index a1092a961be7..4b94f5fe156e 100644 --- a/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp +++ b/core/libs/tests/GuestSystemServiceOpenUrlLimits.cpp @@ -144,9 +144,16 @@ void CheckSuppressedDoesNotUseAttempts() { Require(gate.Run(OpenUrlMode::Ask, "https://example.com/a", launcher, confirmation) == OpenUrlGateOutcome::Limited); } +void CheckDocumentedValues() { + Require(OPEN_URL_MAX_ATTEMPTS == 3); + Require(OPEN_URL_WINDOW == std::chrono::seconds(60)); + Require(OPEN_URL_DECLINE_COOLDOWN == std::chrono::seconds(600)); +} + } int main() { + CheckDocumentedValues(); CheckBusy(); CheckNever(); CheckRateCap();