diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index 04d90be58..4561113eb 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,20 @@ 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 + ${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) endif() add_test_executable(amd64_only_converter_tests @@ -276,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/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp new file mode 100644 index 000000000..b4b95f3ee --- /dev/null +++ b/core/relinker/elfpatcher/include/elfpatcher/linux/LinuxDesktopEntryWriter.hpp @@ -0,0 +1,27 @@ +#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::optional Prepare(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const; + std::filesystem::path Write(const LinuxDesktopEntry& entry) 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..04f3d5f91 --- /dev/null +++ b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp @@ -0,0 +1,132 @@ +#include +#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 '" + 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 '" + 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 '" + Io::Utf8Path(path) + "': 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 Io::Utf8Path(executablePath.stem()); + std::string parsed; + try { + parsed = cleanTitle(parseParamJson(paramJsonPath).title); + } catch (const std::exception& e) { + throw Domain::RelinkerException("Invalid '" + Io::Utf8Path(paramJsonPath) + "': " + e.what()); + } + return parsed.empty() ? Io::Utf8Path(executablePath.stem()) : parsed; +} + +std::string escapeValue(const std::string& value) { + std::string result; + for (const char c : value) { + if (c == '\\') result += "\\\\"; + else result += c; + } + return result; +} + +void requirePrintable(const std::filesystem::path& path) { + 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; + }); + 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) { + if (c == '"' || c == '`' || c == '$' || c == '\\') result += '\\'; + if (c == '%') result += '%'; + result += c; + } + return escapeValue(result + "\""); +} + +} + +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 std::nullopt; + requirePrintable(executablePath); + LinuxDesktopEntry entry; + entry.Icon = readPng(iconSource); + const auto name = title(sceSysDirectory / "param.json", executablePath); + 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(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; +} + +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 '" + 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 '" + 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 '" + Io::Utf8Path(entry.EntryPath) + "' executable: " + error.message()); + return entry.EntryPath; +} + +} diff --git a/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp new file mode 100644 index 000000000..bfbaf19eb --- /dev/null +++ b/core/relinker/elfpatcher/tests/LinuxDesktopEntryTests.cpp @@ -0,0 +1,145 @@ +#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 + "'"); +} + +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().Prepare(sceSys, executable); + } catch (const Domain::RelinkerException& e) { + 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("Prepare succeeded despite " + reason); +} + +void run(const std::filesystem::path& root) { + 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"; + 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(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()); + 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"); + + 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"); + } +} + +} + +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..4d13db9f9 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -111,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 || 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); const std::string registryPath = Io::Utf8Path(outFsPath.parent_path() / Io::NativePath(Io::Utf8Path(outFsPath.stem()) + ".registry.json")); @@ -154,6 +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: " << 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()