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"