Merge pull request #353 from adriadam10/feat/relinker-linux-desktop-entry

feat(relinker): write a desktop entry with the game icon for Linux executables
This commit is contained in:
Adrià 2026-10-11 12:59:31 +02:00 committed by GitHub
commit 0f863c1022
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 437 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,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 $<TARGET_FILE:relinker>)
add_test(NAME windows_icon COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_windows_icon.py $<TARGET_FILE:relinker>)
add_test(NAME linux_desktop_entry_output COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_linux_desktop_entry_output.py $<TARGET_FILE:relinker>)
add_test(NAME guest_intel_trampolines COMMAND ${Python3_EXECUTABLE}
${CMAKE_CURRENT_SOURCE_DIR}/relinker/tests/test_guest_intel_trampolines.py $<TARGET_FILE:relinker>)
add_test(NAME guest_tls COMMAND ${Python3_EXECUTABLE}

View File

@ -0,0 +1,27 @@
#ifndef ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP
#define ELFPATCHER_LINUX_LINUXDESKTOPENTRYWRITER_HPP
#include <cstdint>
#include <filesystem>
#include <optional>
#include <string>
#include <vector>
namespace Elfpatcher::Linux {
struct LinuxDesktopEntry {
std::filesystem::path IconPath;
std::vector<std::uint8_t> Icon;
std::filesystem::path EntryPath;
std::string Text;
};
class LinuxDesktopEntryWriter {
public:
std::optional<LinuxDesktopEntry> Prepare(const std::filesystem::path& sceSysDirectory, const std::filesystem::path& executablePath) const;
std::filesystem::path Write(const LinuxDesktopEntry& entry) const;
};
}
#endif

View File

@ -0,0 +1,132 @@
#include <elfpatcher/linux/LinuxDesktopEntryWriter.hpp>
#include <domain/Types.hpp>
#include <io/NativePath.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 '" + Io::Utf8Path(path) + "': " + 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 '" + Io::Utf8Path(path) + "'");
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 '" + 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<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 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<unsigned char>(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<LinuxDesktopEntry> 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<const char*>(entry.Icon.data()), static_cast<std::streamsize>(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;
}
}

View File

@ -0,0 +1,145 @@
#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 + "'");
}
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;
}

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

View File

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