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] 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"