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 <output>.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 <noreply@anthropic.com>
This commit is contained in:
Adrià Franch 2026-10-02 13:42:28 +02:00
parent 6554fcb68a
commit 1ff7b534fe
5 changed files with 282 additions and 0 deletions

View File

@ -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

View File

@ -0,0 +1,15 @@
#ifndef ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP
#define ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP
#include <filesystem>
namespace Elfpatcher::Linux {
class LinuxDesktopEntryWriter {
public:
std::filesystem::path Write(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const;
};
}
#endif

View File

@ -0,0 +1,114 @@
#include <elfpatcher/linux/LinuxDesktopEntryWriter.hpp>
#include <domain/Types.hpp>
#include "prx/libkernel/AppMetadata/include/ParamJsonParser.hpp"
#include <algorithm>
#include <array>
#include <cstdint>
#include <fstream>
#include <iterator>
#include <string>
#include <string_view>
#include <vector>
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<std::uint8_t> 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<std::uint8_t> bytes{std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
constexpr std::array<std::uint8_t, 16> 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<unsigned char>(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<const char*>(iconBytes.data()), static_cast<std::streamsize>(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;
}
}

View File

@ -0,0 +1,133 @@
#include <elfpatcher/linux/LinuxDesktopEntryWriter.hpp>
#include <domain/Types.hpp>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <map>
#include <sstream>
#include <stdexcept>
#include <string>
#include <vector>
namespace {
const std::vector<std::uint8_t> 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<std::uint8_t>& bytes) {
std::filesystem::create_directories(path.parent_path());
std::ofstream stream(path, std::ios::binary);
stream.write(reinterpret_cast<const char*>(bytes.data()), static_cast<std::streamsize>(bytes.size()));
}
void writeText(const std::filesystem::path& path, const std::string& text) {
writeBytes(path, std::vector<std::uint8_t>(text.begin(), text.end()));
}
std::vector<std::uint8_t> readBytes(const std::filesystem::path& path) {
std::ifstream stream(path, std::ios::binary);
return {std::istreambuf_iterator<char>(stream), std::istreambuf_iterator<char>()};
}
std::string param(const std::string& title) {
return R"({"titleId": "PPSA00000", "defaultLanguage": "en-US", "localizedParameters": {"en-US": {"titleName": ")" + title + R"("}}})";
}
std::map<std::string, std::string> 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<std::string, std::string> 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<std::string, std::string>& 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() ? "<missing>" : 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;
}

View File

@ -3,6 +3,7 @@
#include <io/FileReader.hpp>
#include <io/NativePath.hpp>
#include <elfpatcher/linux/LinuxElfPatcher.hpp>
#include <elfpatcher/linux/LinuxDesktopEntryWriter.hpp>
#include <elfpatcher/general/SegmentFilter.hpp>
#include <elfpatcher/general/EntryStubBuilder.hpp>
#include <elfpatcher/general/ProgramHeaderLayoutBuilder.hpp>
@ -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"