From 1ff7b534fe780bdf8644c3bd596d160a054ef261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Fri, 2 Oct 2026 13:42:28 +0200 Subject: [PATCH 1/3] feat(relinker): write a desktop entry with the game icon for Linux executables ELF has no icon resource, so when sce_sys/icon0.png sits beside the input the relinker copies it next to the output and writes .desktop (freedesktop Desktop Entry) with that icon. Name is the param.json title without trademark signs or line breaks, or the output name without param.json. Exec is quoted per the spec and the entry is marked executable so file managers launch it. A malformed icon0.png or param.json fails the relink; without icon0.png nothing extra is written. Co-Authored-By: Claude --- core/relinker/CMakeLists.txt | 15 ++ .../linux/LinuxDesktopEntryWriter.hpp | 15 ++ .../src/linux/LinuxDesktopEntryWriter.cpp | 114 +++++++++++++++ .../tests/LinuxDesktopEntryTests.cpp | 133 ++++++++++++++++++ core/relinker/main.cpp | 5 + 5 files changed, 282 insertions(+) create mode 100644 core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp create mode 100644 core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp create mode 100644 core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 04d90be58..e914315a1 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -4,6 +4,8 @@ add_executable(relinker ${CMAKE_CURRENT_SOURCE_DIR}/cli/src/Autorun.cpp ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/linux/LinuxElfPatcher.cpp ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/linux/LinuxGuestModuleWriter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../libs/prx/libkernel/AppMetadata/src/ParamJsonParser.cpp ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/windows/WindowsGuestModuleWriter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/windows/WindowsGuestStartup.cpp ${CMAKE_CURRENT_SOURCE_DIR}/relinker/src/guest/GuestImageReader.cpp @@ -76,6 +78,7 @@ target_include_directories(relinker PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/io/include ${CMAKE_CURRENT_SOURCE_DIR}/codegen/include ${CMAKE_CURRENT_SOURCE_DIR}/relinker/include + ${CMAKE_CURRENT_SOURCE_DIR}/../libs ) if(WIN32) @@ -172,6 +175,18 @@ if(BUILD_TESTING) ) add_test(NAME file_writer_binary COMMAND file_writer_tests binary) add_test(NAME file_writer_text COMMAND file_writer_tests text) + + add_test_executable(linux_desktop_entry_tests + ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/tests/LinuxDesktopEntryTests.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../libs/prx/libkernel/AppMetadata/src/ParamJsonParser.cpp + ) + target_include_directories(linux_desktop_entry_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/domain/include + ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/include + ${CMAKE_CURRENT_SOURCE_DIR}/../libs + ) + add_test(NAME linux_desktop_entry COMMAND linux_desktop_entry_tests) endif() add_test_executable(amd64_only_converter_tests diff --git a/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp new file mode 100644 index 000000000..fbefc0d6d --- /dev/null +++ b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp @@ -0,0 +1,15 @@ +#ifndef ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP +#define ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP + +#include + +namespace Elfpatcher::Linux { + +class LinuxDesktopEntryWriter { +public: + std::filesystem::path Write(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const; +}; + +} + +#endif diff --git a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp new file mode 100644 index 000000000..97f8beda3 --- /dev/null +++ b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp @@ -0,0 +1,114 @@ +#include +#include +#include "prx/libkernel/AppMetadata/include/ParamJsonParser.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace Elfpatcher::Linux { + +namespace { + +bool fileExists(const std::filesystem::path& path) { + std::error_code error; + const bool result = std::filesystem::exists(path, error); + if (error) throw Domain::RelinkerException("Cannot inspect '" + path.string() + "': " + error.message()); + return result; +} + +std::vector readPng(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + if (!stream) throw Domain::RelinkerException("Cannot open icon '" + path.string() + "'"); + std::vector bytes{std::istreambuf_iterator(stream), std::istreambuf_iterator()}; + constexpr std::array header{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}; + if (bytes.size() < 33 || !std::equal(header.begin(), header.end(), bytes.begin())) throw Domain::RelinkerException("Invalid icon PNG '" + path.string() + "': missing PNG signature or IHDR"); + return bytes; +} + +std::string cleanTitle(const std::string& title) { + std::string withoutMarks; + for (std::size_t i = 0; i < title.size(); ++i) { + const std::string_view rest(title.data() + i, title.size() - i); + if (rest.starts_with("\xe2\x84\xa2")) { i += 2; continue; } + if (rest.starts_with("\xc2\xae") || rest.starts_with("\xc2\xa9")) { i += 1; continue; } + const auto c = static_cast(title[i]); + withoutMarks += c < 0x20 || c == 0x7f ? ' ' : title[i]; + } + std::string result; + for (const char c : withoutMarks) { + if (c == ' ' && (result.empty() || result.back() == ' ')) continue; + result += c; + } + if (!result.empty() && result.back() == ' ') result.pop_back(); + return result; +} + +std::string title(const std::filesystem::path& paramJsonPath, const std::filesystem::path& executablePath) { + if (!fileExists(paramJsonPath)) return executablePath.stem().string(); + std::string parsed; + try { + parsed = cleanTitle(parseParamJson(paramJsonPath).title); + } catch (const std::exception& e) { + throw Domain::RelinkerException("Invalid '" + paramJsonPath.string() + "': " + e.what()); + } + return parsed.empty() ? executablePath.stem().string() : parsed; +} + +std::string escapeValue(const std::string& value) { + std::string result; + for (const char c : value) { + if (c == '\\') result += "\\\\"; + else result += c; + } + return result; +} + +std::string quoteExec(const std::string& path) { + std::string result = "\""; + for (const char c : path) { + if (c == '"' || c == '`' || c == '$' || c == '\\') result += '\\'; + if (c == '%') result += '%'; + result += c; + } + return escapeValue(result + "\""); +} + +} + +std::filesystem::path LinuxDesktopEntryWriter::Write(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const { + const auto iconSource = sceSysDirectory / "icon0.png"; + if (!fileExists(iconSource)) return {}; + const auto iconBytes = readPng(iconSource); + const auto name = title(sceSysDirectory / "param.json", executablePath); + + const auto iconPath = std::filesystem::path(executablePath.string() + ".png"); + std::ofstream icon(iconPath, std::ios::binary); + icon.write(reinterpret_cast(iconBytes.data()), static_cast(iconBytes.size())); + icon.close(); + if (!icon) throw Domain::RelinkerException("Failed to write icon '" + iconPath.string() + "'"); + + const auto entryPath = std::filesystem::path(executablePath.string() + ".desktop"); + std::ofstream entry(entryPath, std::ios::binary); + entry << "[Desktop Entry]\n" + << "Type=Application\n" + << "Name=" << escapeValue(name) << '\n' + << "Exec=" << quoteExec(executablePath.string()) << '\n' + << "Path=" << escapeValue(executablePath.parent_path().string()) << '\n' + << "Icon=" << escapeValue(iconPath.string()) << '\n' + << "Terminal=false\n" + << "Categories=Game;\n"; + entry.close(); + if (!entry) throw Domain::RelinkerException("Failed to write desktop entry '" + entryPath.string() + "'"); + + std::error_code error; + std::filesystem::permissions(entryPath, std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, error); + if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + entryPath.string() + "' executable: " + error.message()); + return entryPath; +} + +} diff --git a/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp new file mode 100644 index 000000000..37185e5ec --- /dev/null +++ b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp @@ -0,0 +1,133 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +const std::vector Png{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0, 0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, + 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82}; + +void require(const bool condition, const std::string& message) { + if (!condition) throw std::runtime_error(message); +} + +void writeBytes(const std::filesystem::path& path, const std::vector& bytes) { + std::filesystem::create_directories(path.parent_path()); + std::ofstream stream(path, std::ios::binary); + stream.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())); +} + +void writeText(const std::filesystem::path& path, const std::string& text) { + writeBytes(path, std::vector(text.begin(), text.end())); +} + +std::vector readBytes(const std::filesystem::path& path) { + std::ifstream stream(path, std::ios::binary); + return {std::istreambuf_iterator(stream), std::istreambuf_iterator()}; +} + +std::string param(const std::string& title) { + return R"({"titleId": "PPSA00000", "defaultLanguage": "en-US", "localizedParameters": {"en-US": {"titleName": ")" + title + R"("}}})"; +} + +std::map readEntry(const std::filesystem::path& path) { + std::ifstream stream(path); + std::string line; + std::getline(stream, line); + require(line == "[Desktop Entry]", "Missing [Desktop Entry] group in " + path.string()); + std::map fields; + while (std::getline(stream, line)) { + const auto separator = line.find('='); + require(separator != std::string::npos, "Malformed desktop entry line: " + line); + fields[line.substr(0, separator)] = line.substr(separator + 1); + } + return fields; +} + +void requireField(const std::map& fields, const std::string& key, const std::string& expected) { + const auto found = fields.find(key); + require(found != fields.end() && found->second == expected, key + " is '" + (found == fields.end() ? "" : found->second) + "', expected '" + expected + "'"); +} + +void requireFailure(const std::filesystem::path& sceSys, const std::filesystem::path& executable, const std::string& path) { + try { + Elfpatcher::Linux::LinuxDesktopEntryWriter().Write(sceSys, executable); + } catch (const Domain::RelinkerException& e) { + require(std::string(e.what()).find(path) != std::string::npos, "Failure does not name " + path + ": " + e.what()); + require(!std::filesystem::exists(executable.string() + ".desktop"), "Desktop entry written despite the failure"); + return; + } + throw std::runtime_error("Write succeeded for a malformed " + path); +} + +void run(const std::filesystem::path& root) { + const Elfpatcher::Linux::LinuxDesktopEntryWriter writer; + + require(writer.Write(root / "none" / "sce_sys", root / "none" / "out" / "game").empty(), "Desktop entry written without icon0.png"); + require(!std::filesystem::exists(root / "none" / "out"), "Files written without icon0.png"); + + const auto titledSceSys = root / "titled" / "game" / "sce_sys"; + const auto titled = root / "titled" / "out dir" / "game"; + writeBytes(titledSceSys / "icon0.png", Png); + writeText(titledSceSys / "param.json", param("Some™ Game®:\\n Edition© ")); + std::filesystem::create_directories(titled.parent_path()); + require(writer.Write(titledSceSys, titled) == titled.string() + ".desktop", "Unexpected desktop entry path"); + const auto fields = readEntry(titled.string() + ".desktop"); + requireField(fields, "Type", "Application"); + requireField(fields, "Name", "Some Game: Edition"); + requireField(fields, "Exec", "\"" + titled.string() + "\""); + requireField(fields, "Path", titled.parent_path().string()); + requireField(fields, "Icon", titled.string() + ".png"); + requireField(fields, "Terminal", "false"); + require(readBytes(titled.string() + ".png") == Png, "Icon bytes changed"); + const auto permissions = std::filesystem::status(titled.string() + ".desktop").permissions(); + require((permissions & std::filesystem::perms::owner_exec) != std::filesystem::perms::none, "Desktop entry is not executable"); + + const auto untitledSceSys = root / "untitled" / "game" / "sce_sys"; + const auto untitled = root / "untitled" / "out" / "100% $HOME"; + writeBytes(untitledSceSys / "icon0.png", Png); + std::filesystem::create_directories(untitled.parent_path()); + writer.Write(untitledSceSys, untitled); + const auto untitledFields = readEntry(untitled.string() + ".desktop"); + requireField(untitledFields, "Name", "100% $HOME"); + requireField(untitledFields, "Exec", "\"" + (root / "untitled" / "out").string() + "/100%% \\\\$HOME\""); + + const auto badPngSceSys = root / "badpng" / "game" / "sce_sys"; + writeText(badPngSceSys / "icon0.png", "GIF89a" + std::string(40, '\0')); + std::filesystem::create_directories(root / "badpng" / "out"); + requireFailure(badPngSceSys, root / "badpng" / "out" / "game", "icon0.png"); + + const auto badParamSceSys = root / "badparam" / "game" / "sce_sys"; + writeBytes(badParamSceSys / "icon0.png", Png); + writeText(badParamSceSys / "param.json", "{"); + std::filesystem::create_directories(root / "badparam" / "out"); + requireFailure(badParamSceSys, root / "badparam" / "out" / "game", "param.json"); +} + +} + +int main() { + const auto root = std::filesystem::temp_directory_path() / "anyps5-desktop-entry-tests"; + std::filesystem::remove_all(root); + try { + run(root); + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + std::filesystem::remove_all(root); + return 1; + } + std::filesystem::remove_all(root); + return 0; +} diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index c43d97743..b1b0d0581 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -154,6 +155,10 @@ int Run(const int argc, char* argv[]) { std::cout << "Guest module: " << Io::Utf8Path(artifact.Path) << '\n'; } fileWriter.Write(absPath, executableBytes); + if (!args.toWindows) { + const auto desktopEntry = Elfpatcher::Linux::LinuxDesktopEntryWriter().Write(std::filesystem::path(args.inputPath).parent_path() / "sce_sys", absPath); + if (!desktopEntry.empty()) std::cout << "Desktop entry: " << desktopEntry.string() << '\n'; + } std::cout << "External prx references: " << result.RegistryEntries.size() << "\nOutput file: " << absPath << '\n'; std::cout << "Expected runtime layout (relative to the output executable):\n" << Io::Utf8Path(Io::NativePath(absPath).filename()) << "\n" From ca330dfefbe73b85207759b17db89b0913d6ea3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Sun, 4 Oct 2026 08:41:57 +0200 Subject: [PATCH 2/3] fix(relinker): validate the desktop entry before writing the executable The icon, title and output path are checked before the ELF is written, so a malformed icon0.png or param.json leaves no output behind. Paths with control characters are refused instead of producing an invalid entry. Co-Authored-By: Claude --- .../linux/LinuxDesktopEntryWriter.hpp | 14 ++++- .../src/linux/LinuxDesktopEntryWriter.cpp | 59 ++++++++++++------- .../tests/LinuxDesktopEntryTests.cpp | 32 ++++++---- core/relinker/main.cpp | 8 +-- 4 files changed, 76 insertions(+), 37 deletions(-) diff --git a/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp index fbefc0d6d..b4b95f3ee 100644 --- a/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp +++ b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp @@ -1,13 +1,25 @@ #ifndef ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP #define ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP +#include #include +#include +#include +#include namespace Elfpatcher::Linux { +struct LinuxDesktopEntry { + std::filesystem::path IconPath; + std::vector Icon; + std::filesystem::path EntryPath; + std::string Text; +}; + class LinuxDesktopEntryWriter { public: - std::filesystem::path Write(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const; + std::optional Prepare(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const; + std::filesystem::path Write(const LinuxDesktopEntry& entry) const; }; } diff --git a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp index 97f8beda3..8dbe77091 100644 --- a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp +++ b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp @@ -68,6 +68,15 @@ std::string escapeValue(const std::string& value) { return result; } +void requirePrintable(const std::filesystem::path& path) { + const auto text = path.string(); + const bool control = std::any_of(text.begin(), text.end(), [](const char c) { + const auto byte = static_cast(c); + return byte < 0x20 || byte == 0x7f; + }); + if (control) throw Domain::RelinkerException("Cannot write a desktop entry for '" + text + "': the path contains a control character"); +} + std::string quoteExec(const std::string& path) { std::string result = "\""; for (const char c : path) { @@ -80,35 +89,41 @@ std::string quoteExec(const std::string& path) { } -std::filesystem::path LinuxDesktopEntryWriter::Write(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const { +std::optional LinuxDesktopEntryWriter::Prepare(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const { const auto iconSource = sceSysDirectory / "icon0.png"; - if (!fileExists(iconSource)) return {}; - const auto iconBytes = readPng(iconSource); + if (!fileExists(iconSource)) return std::nullopt; + requirePrintable(executablePath); + LinuxDesktopEntry entry; + entry.Icon = readPng(iconSource); const auto name = title(sceSysDirectory / "param.json", executablePath); + entry.IconPath = std::filesystem::path(executablePath.string() + ".png"); + entry.EntryPath = std::filesystem::path(executablePath.string() + ".desktop"); + entry.Text = "[Desktop Entry]\n" + "Type=Application\n" + "Name=" + escapeValue(name) + "\n" + "Exec=" + quoteExec(executablePath.string()) + "\n" + "Path=" + escapeValue(executablePath.parent_path().string()) + "\n" + "Icon=" + escapeValue(entry.IconPath.string()) + "\n" + "Terminal=false\n" + "Categories=Game;\n"; + return entry; +} - const auto iconPath = std::filesystem::path(executablePath.string() + ".png"); - std::ofstream icon(iconPath, std::ios::binary); - icon.write(reinterpret_cast(iconBytes.data()), static_cast(iconBytes.size())); +std::filesystem::path LinuxDesktopEntryWriter::Write(const LinuxDesktopEntry& entry) const { + std::ofstream icon(entry.IconPath, std::ios::binary); + icon.write(reinterpret_cast(entry.Icon.data()), static_cast(entry.Icon.size())); icon.close(); - if (!icon) throw Domain::RelinkerException("Failed to write icon '" + iconPath.string() + "'"); + if (!icon) throw Domain::RelinkerException("Failed to write icon '" + entry.IconPath.string() + "'"); - const auto entryPath = std::filesystem::path(executablePath.string() + ".desktop"); - std::ofstream entry(entryPath, std::ios::binary); - entry << "[Desktop Entry]\n" - << "Type=Application\n" - << "Name=" << escapeValue(name) << '\n' - << "Exec=" << quoteExec(executablePath.string()) << '\n' - << "Path=" << escapeValue(executablePath.parent_path().string()) << '\n' - << "Icon=" << escapeValue(iconPath.string()) << '\n' - << "Terminal=false\n" - << "Categories=Game;\n"; - entry.close(); - if (!entry) throw Domain::RelinkerException("Failed to write desktop entry '" + entryPath.string() + "'"); + std::ofstream text(entry.EntryPath, std::ios::binary); + text << entry.Text; + text.close(); + if (!text) throw Domain::RelinkerException("Failed to write desktop entry '" + entry.EntryPath.string() + "'"); std::error_code error; - std::filesystem::permissions(entryPath, std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, error); - if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + entryPath.string() + "' executable: " + error.message()); - return entryPath; + std::filesystem::permissions(entry.EntryPath, std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, error); + if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + entry.EntryPath.string() + "' executable: " + error.message()); + return entry.EntryPath; } } diff --git a/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp index 37185e5ec..bfbaf19eb 100644 --- a/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp +++ b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp @@ -61,21 +61,25 @@ void requireField(const std::map& fields, const std::s require(found != fields.end() && found->second == expected, key + " is '" + (found == fields.end() ? "" : found->second) + "', expected '" + expected + "'"); } -void requireFailure(const std::filesystem::path& sceSys, const std::filesystem::path& executable, const std::string& path) { +std::filesystem::path write(const std::filesystem::path& sceSys, const std::filesystem::path& executable) { + const Elfpatcher::Linux::LinuxDesktopEntryWriter writer; + const auto entry = writer.Prepare(sceSys, executable); + return entry ? writer.Write(*entry) : std::filesystem::path{}; +} + +void requireFailure(const std::filesystem::path& sceSys, const std::filesystem::path& executable, const std::string& reason) { try { - Elfpatcher::Linux::LinuxDesktopEntryWriter().Write(sceSys, executable); + Elfpatcher::Linux::LinuxDesktopEntryWriter().Prepare(sceSys, executable); } catch (const Domain::RelinkerException& e) { - require(std::string(e.what()).find(path) != std::string::npos, "Failure does not name " + path + ": " + e.what()); - require(!std::filesystem::exists(executable.string() + ".desktop"), "Desktop entry written despite the failure"); + require(std::string(e.what()).find(reason) != std::string::npos, "Failure does not name " + reason + ": " + e.what()); + require(!std::filesystem::exists(executable.string() + ".desktop") && !std::filesystem::exists(executable.string() + ".png"), "Files written despite the failure"); return; } - throw std::runtime_error("Write succeeded for a malformed " + path); + throw std::runtime_error("Prepare succeeded despite " + reason); } void run(const std::filesystem::path& root) { - const Elfpatcher::Linux::LinuxDesktopEntryWriter writer; - - require(writer.Write(root / "none" / "sce_sys", root / "none" / "out" / "game").empty(), "Desktop entry written without icon0.png"); + require(write(root / "none" / "sce_sys", root / "none" / "out" / "game").empty(), "Desktop entry written without icon0.png"); require(!std::filesystem::exists(root / "none" / "out"), "Files written without icon0.png"); const auto titledSceSys = root / "titled" / "game" / "sce_sys"; @@ -83,7 +87,7 @@ void run(const std::filesystem::path& root) { writeBytes(titledSceSys / "icon0.png", Png); writeText(titledSceSys / "param.json", param("Some™ Game®:\\n Edition© ")); std::filesystem::create_directories(titled.parent_path()); - require(writer.Write(titledSceSys, titled) == titled.string() + ".desktop", "Unexpected desktop entry path"); + require(write(titledSceSys, titled) == titled.string() + ".desktop", "Unexpected desktop entry path"); const auto fields = readEntry(titled.string() + ".desktop"); requireField(fields, "Type", "Application"); requireField(fields, "Name", "Some Game: Edition"); @@ -99,7 +103,7 @@ void run(const std::filesystem::path& root) { const auto untitled = root / "untitled" / "out" / "100% $HOME"; writeBytes(untitledSceSys / "icon0.png", Png); std::filesystem::create_directories(untitled.parent_path()); - writer.Write(untitledSceSys, untitled); + write(untitledSceSys, untitled); const auto untitledFields = readEntry(untitled.string() + ".desktop"); requireField(untitledFields, "Name", "100% $HOME"); requireField(untitledFields, "Exec", "\"" + (root / "untitled" / "out").string() + "/100%% \\\\$HOME\""); @@ -114,6 +118,14 @@ void run(const std::filesystem::path& root) { writeText(badParamSceSys / "param.json", "{"); std::filesystem::create_directories(root / "badparam" / "out"); requireFailure(badParamSceSys, root / "badparam" / "out" / "game", "param.json"); + + const auto controlSceSys = root / "control" / "game" / "sce_sys"; + writeBytes(controlSceSys / "icon0.png", Png); + for (const char control : {'\n', '\t', '\r', '\x7f'}) { + const auto directory = root / "control" / (std::string("out") + control + "dir"); + std::filesystem::create_directories(directory); + requireFailure(controlSceSys, directory / "game", "control character"); + } } } diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index b1b0d0581..0cdb22105 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -112,6 +112,9 @@ int Run(const int argc, char* argv[]) { guestArtifacts = Relinker::GuestModuleBuilder().Build(Io::NativePath(args.inputPath), Io::NativePath(absPath), result.DynamicSection, args.toWindows, args.toMacos, args.toIntel, args.toRosetta, *syscallScanner, args.lazyBinding, args.runPath, args.excludedSceModules, Io::NativePath(args.sceModulePath), args.moduleDirectories); } + const Elfpatcher::Linux::LinuxDesktopEntryWriter desktopEntryWriter; + const auto desktopEntry = args.toWindows ? std::nullopt : desktopEntryWriter.Prepare(std::filesystem::path(args.inputPath).parent_path() / "sce_sys", absPath); + if (args.writeRegistry) { const std::filesystem::path outFsPath = Io::NativePath(absPath); const std::string registryPath = Io::Utf8Path(outFsPath.parent_path() / Io::NativePath(Io::Utf8Path(outFsPath.stem()) + ".registry.json")); @@ -155,10 +158,7 @@ int Run(const int argc, char* argv[]) { std::cout << "Guest module: " << Io::Utf8Path(artifact.Path) << '\n'; } fileWriter.Write(absPath, executableBytes); - if (!args.toWindows) { - const auto desktopEntry = Elfpatcher::Linux::LinuxDesktopEntryWriter().Write(std::filesystem::path(args.inputPath).parent_path() / "sce_sys", absPath); - if (!desktopEntry.empty()) std::cout << "Desktop entry: " << desktopEntry.string() << '\n'; - } + if (desktopEntry) std::cout << "Desktop entry: " << desktopEntryWriter.Write(*desktopEntry).string() << '\n'; std::cout << "External prx references: " << result.RegistryEntries.size() << "\nOutput file: " << absPath << '\n'; std::cout << "Expected runtime layout (relative to the output executable):\n" << Io::Utf8Path(Io::NativePath(absPath).filename()) << "\n" From 33a1ca5d333a09a4b0d914b2b45541a12f0afda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Franch?= Date: Sun, 11 Oct 2026 12:45:21 +0200 Subject: [PATCH 3/3] fix(relinker): skip the desktop entry for macOS output and use native paths The Linux desktop entry was written for every non-Windows output, macOS included since --macos landed. It is now written only for Linux output, and its paths go through Io::NativePath/Utf8Path like the rest of the relinker. An end-to-end test relinks for Linux, macOS and Windows. Co-Authored-By: Claude --- core/relinker/CMakeLists.txt | 4 + .../src/linux/LinuxDesktopEntryWriter.cpp | 33 +++--- core/relinker/main.cpp | 4 +- .../tests/test_linux_desktop_entry_output.py | 109 ++++++++++++++++++ 4 files changed, 133 insertions(+), 17 deletions(-) create mode 100644 core/relinker/relinker/tests/test_linux_desktop_entry_output.py diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index e914315a1..4561113eb 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -180,10 +180,12 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/tests/LinuxDesktopEntryTests.cpp ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../libs/prx/libkernel/AppMetadata/src/ParamJsonParser.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/io/src/NativePath.cpp ) target_include_directories(linux_desktop_entry_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/domain/include ${CMAKE_CURRENT_SOURCE_DIR}/elfpatcher/include + ${CMAKE_CURRENT_SOURCE_DIR}/io/include ${CMAKE_CURRENT_SOURCE_DIR}/../libs ) add_test(NAME linux_desktop_entry COMMAND linux_desktop_entry_tests) @@ -291,6 +293,8 @@ if(BUILD_TESTING) ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_bss.py $) add_test(NAME windows_icon COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_icon.py $) + add_test(NAME linux_desktop_entry_output COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_linux_desktop_entry_output.py $) add_test(NAME guest_intel_trampolines COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_intel_trampolines.py $) add_test(NAME guest_tls COMMAND ${Python3_EXECUTABLE} diff --git a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp index 8dbe77091..04f3d5f91 100644 --- a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp +++ b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "prx/libkernel/AppMetadata/include/ParamJsonParser.hpp" #include #include @@ -17,16 +18,16 @@ namespace { bool fileExists(const std::filesystem::path& path) { std::error_code error; const bool result = std::filesystem::exists(path, error); - if (error) throw Domain::RelinkerException("Cannot inspect '" + path.string() + "': " + error.message()); + if (error) throw Domain::RelinkerException("Cannot inspect '" + Io::Utf8Path(path) + "': " + error.message()); return result; } std::vector readPng(const std::filesystem::path& path) { std::ifstream stream(path, std::ios::binary); - if (!stream) throw Domain::RelinkerException("Cannot open icon '" + path.string() + "'"); + if (!stream) throw Domain::RelinkerException("Cannot open icon '" + Io::Utf8Path(path) + "'"); std::vector bytes{std::istreambuf_iterator(stream), std::istreambuf_iterator()}; constexpr std::array header{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52}; - if (bytes.size() < 33 || !std::equal(header.begin(), header.end(), bytes.begin())) throw Domain::RelinkerException("Invalid icon PNG '" + path.string() + "': missing PNG signature or IHDR"); + if (bytes.size() < 33 || !std::equal(header.begin(), header.end(), bytes.begin())) throw Domain::RelinkerException("Invalid icon PNG '" + Io::Utf8Path(path) + "': missing PNG signature or IHDR"); return bytes; } @@ -49,14 +50,14 @@ std::string cleanTitle(const std::string& title) { } std::string title(const std::filesystem::path& paramJsonPath, const std::filesystem::path& executablePath) { - if (!fileExists(paramJsonPath)) return executablePath.stem().string(); + if (!fileExists(paramJsonPath)) return Io::Utf8Path(executablePath.stem()); std::string parsed; try { parsed = cleanTitle(parseParamJson(paramJsonPath).title); } catch (const std::exception& e) { - throw Domain::RelinkerException("Invalid '" + paramJsonPath.string() + "': " + e.what()); + throw Domain::RelinkerException("Invalid '" + Io::Utf8Path(paramJsonPath) + "': " + e.what()); } - return parsed.empty() ? executablePath.stem().string() : parsed; + return parsed.empty() ? Io::Utf8Path(executablePath.stem()) : parsed; } std::string escapeValue(const std::string& value) { @@ -69,7 +70,7 @@ std::string escapeValue(const std::string& value) { } void requirePrintable(const std::filesystem::path& path) { - const auto text = path.string(); + const auto text = Io::Utf8Path(path); const bool control = std::any_of(text.begin(), text.end(), [](const char c) { const auto byte = static_cast(c); return byte < 0x20 || byte == 0x7f; @@ -96,14 +97,16 @@ std::optional LinuxDesktopEntryWriter::Prepare(const std::fil LinuxDesktopEntry entry; entry.Icon = readPng(iconSource); const auto name = title(sceSysDirectory / "param.json", executablePath); - entry.IconPath = std::filesystem::path(executablePath.string() + ".png"); - entry.EntryPath = std::filesystem::path(executablePath.string() + ".desktop"); + entry.IconPath = executablePath; + entry.IconPath += ".png"; + entry.EntryPath = executablePath; + entry.EntryPath += ".desktop"; entry.Text = "[Desktop Entry]\n" "Type=Application\n" "Name=" + escapeValue(name) + "\n" - "Exec=" + quoteExec(executablePath.string()) + "\n" - "Path=" + escapeValue(executablePath.parent_path().string()) + "\n" - "Icon=" + escapeValue(entry.IconPath.string()) + "\n" + "Exec=" + quoteExec(Io::Utf8Path(executablePath)) + "\n" + "Path=" + escapeValue(Io::Utf8Path(executablePath.parent_path())) + "\n" + "Icon=" + escapeValue(Io::Utf8Path(entry.IconPath)) + "\n" "Terminal=false\n" "Categories=Game;\n"; return entry; @@ -113,16 +116,16 @@ std::filesystem::path LinuxDesktopEntryWriter::Write(const LinuxDesktopEntry& en std::ofstream icon(entry.IconPath, std::ios::binary); icon.write(reinterpret_cast(entry.Icon.data()), static_cast(entry.Icon.size())); icon.close(); - if (!icon) throw Domain::RelinkerException("Failed to write icon '" + entry.IconPath.string() + "'"); + if (!icon) throw Domain::RelinkerException("Failed to write icon '" + Io::Utf8Path(entry.IconPath) + "'"); std::ofstream text(entry.EntryPath, std::ios::binary); text << entry.Text; text.close(); - if (!text) throw Domain::RelinkerException("Failed to write desktop entry '" + entry.EntryPath.string() + "'"); + if (!text) throw Domain::RelinkerException("Failed to write desktop entry '" + Io::Utf8Path(entry.EntryPath) + "'"); std::error_code error; std::filesystem::permissions(entry.EntryPath, std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | std::filesystem::perms::others_exec, std::filesystem::perm_options::add, error); - if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + entry.EntryPath.string() + "' executable: " + error.message()); + if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + Io::Utf8Path(entry.EntryPath) + "' executable: " + error.message()); return entry.EntryPath; } diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index 0cdb22105..4d13db9f9 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -113,7 +113,7 @@ int Run(const int argc, char* argv[]) { } const Elfpatcher::Linux::LinuxDesktopEntryWriter desktopEntryWriter; - const auto desktopEntry = args.toWindows ? std::nullopt : desktopEntryWriter.Prepare(std::filesystem::path(args.inputPath).parent_path() / "sce_sys", absPath); + const auto desktopEntry = args.toWindows || args.toMacos ? std::nullopt : desktopEntryWriter.Prepare(Io::NativePath(args.inputPath).parent_path() / "sce_sys", Io::NativePath(absPath)); if (args.writeRegistry) { const std::filesystem::path outFsPath = Io::NativePath(absPath); @@ -158,7 +158,7 @@ int Run(const int argc, char* argv[]) { std::cout << "Guest module: " << Io::Utf8Path(artifact.Path) << '\n'; } fileWriter.Write(absPath, executableBytes); - if (desktopEntry) std::cout << "Desktop entry: " << desktopEntryWriter.Write(*desktopEntry).string() << '\n'; + if (desktopEntry) std::cout << "Desktop entry: " << Io::Utf8Path(desktopEntryWriter.Write(*desktopEntry)) << '\n'; std::cout << "External prx references: " << result.RegistryEntries.size() << "\nOutput file: " << absPath << '\n'; std::cout << "Expected runtime layout (relative to the output executable):\n" << Io::Utf8Path(Io::NativePath(absPath).filename()) << "\n" diff --git a/core/relinker/relinker/tests/test_linux_desktop_entry_output.py b/core/relinker/relinker/tests/test_linux_desktop_entry_output.py new file mode 100644 index 000000000..88b839e09 --- /dev/null +++ b/core/relinker/relinker/tests/test_linux_desktop_entry_output.py @@ -0,0 +1,109 @@ +from pathlib import Path +import json +import os +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib + +from test_guest_intel_trampolines import main_fixture as fixture + + +def chunk(kind, data): + return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", zlib.crc32(kind + data)) + + +def png(width, height): + rows = b"".join(b"\0" + bytes((15, 65, 200, 255)) * width for _ in range(height)) + return (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) + + +def unescape(value): + result, index = "", 0 + while index < len(value): + if value[index] == "\\" and index + 1 < len(value): + result += {"s": " ", "n": "\n", "t": "\t", "r": "\r", "\\": "\\"}[value[index + 1]] + index += 2 + else: + result += value[index] + index += 1 + return result + + +def unquote(value): + assert value.startswith('"') and value.endswith('"'), value + result, index = "", 1 + while index < len(value) - 1: + if value[index] == "\\": + assert value[index + 1] in '"`$\\', value + index += 1 + elif value[index] == "%": + assert value[index + 1] == "%", value + index += 1 + result += value[index] + index += 1 + return result + + +def entry(path): + lines = path.read_text(encoding="utf-8").splitlines() + assert lines[0] == "[Desktop Entry]", lines + return dict(line.split("=", 1) for line in lines[1:]) + + +def main(): + relinker = Path(sys.argv[1]).resolve() + with tempfile.TemporaryDirectory(prefix="anyps5-desktop-") as directory: + work = Path(directory) + app = work / "app with spaces" + (app / "sce_sys").mkdir(parents=True) + source = app / "eboot.bin" + source.write_bytes(fixture()) + + def relink(name, *options): + output = work / name + result = subprocess.run([str(relinker), "--skip-sce-module", *options, str(source), str(output)], + cwd=work, capture_output=True, text=True, timeout=20) + desktop, icon = Path(f"{output}.desktop"), Path(f"{output}.png") + return result, output, desktop, icon + + result, output, desktop, icon = relink("no-icon") + assert result.returncode == 0, (result.stdout, result.stderr) + assert output.exists() and not desktop.exists() and not icon.exists() + + image = png(64, 64) + (app / "sce_sys" / "icon0.png").write_bytes(image) + (app / "sce_sys" / "param.json").write_text(json.dumps( + {"titleId": "PPSA00000", "defaultLanguage": "en-US", + "localizedParameters": {"en-US": {"titleName": "Sample Title"}}})) + + result, output, desktop, icon = relink("linux") + assert result.returncode == 0, (result.stdout, result.stderr) + assert icon.read_bytes() == image + fields = entry(desktop) + assert fields["Name"] == "Sample Title", fields + assert unquote(unescape(fields["Exec"])) == str(output), fields + assert unescape(fields["Path"]) == str(work) and unescape(fields["Icon"]) == str(icon), fields + assert os.name == "nt" or desktop.stat().st_mode & 0o111 + validator = shutil.which("desktop-file-validate") + if validator: + checked = subprocess.run([validator, str(desktop)], capture_output=True, text=True) + assert checked.returncode == 0 and not checked.stdout.strip(), checked.stdout + + for name, option in (("macos", "--macos"), ("windows.exe", "--windows")): + result, output, desktop, icon = relink(name, option) + assert result.returncode == 0, (name, result.stdout, result.stderr) + assert output.exists() and not desktop.exists() and not icon.exists(), name + + (app / "sce_sys" / "icon0.png").write_bytes(b"not a PNG") + result, output, desktop, icon = relink("bad-icon") + assert result.returncode != 0, (result.stdout, result.stderr) + assert not output.exists() and not desktop.exists() and not icon.exists() + assert "Invalid icon PNG" in result.stderr, result.stderr + + +if __name__ == "__main__": + main()