fix(relinker): skip the desktop entry for macOS output and use native paths

The Linux desktop entry was written for every non-Windows output, macOS
included since --macos landed. It is now written only for Linux output,
and its paths go through Io::NativePath/Utf8Path like the rest of the
relinker. An end-to-end test relinks for Linux, macOS and Windows.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Adrià Franch 2026-10-11 12:45:21 +02:00
parent ca330dfefb
commit 33a1ca5d33
4 changed files with 133 additions and 17 deletions

View File

@ -180,10 +180,12 @@ if(BUILD_TESTING)
${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)
@ -291,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

@ -1,5 +1,6 @@
#include <elfpatcher/linux/LinuxDesktopEntryWriter.hpp>
#include <domain/Types.hpp>
#include <io/NativePath.hpp>
#include "prx/libkernel/AppMetadata/include/ParamJsonParser.hpp"
#include <algorithm>
#include <array>
@ -17,16 +18,16 @@ 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());
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 '" + path.string() + "'");
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 '" + path.string() + "': missing PNG signature or IHDR");
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;
}
@ -49,14 +50,14 @@ std::string cleanTitle(const std::string& title) {
}
std::string title(const std::filesystem::path& paramJsonPath, const std::filesystem::path& executablePath) {
if (!fileExists(paramJsonPath)) return executablePath.stem().string();
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 '" + paramJsonPath.string() + "': " + e.what());
throw Domain::RelinkerException("Invalid '" + Io::Utf8Path(paramJsonPath) + "': " + e.what());
}
return parsed.empty() ? executablePath.stem().string() : parsed;
return parsed.empty() ? Io::Utf8Path(executablePath.stem()) : parsed;
}
std::string escapeValue(const std::string& value) {
@ -69,7 +70,7 @@ std::string escapeValue(const std::string& value) {
}
void requirePrintable(const std::filesystem::path& path) {
const auto text = path.string();
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;
@ -96,14 +97,16 @@ std::optional<LinuxDesktopEntry> LinuxDesktopEntryWriter::Prepare(const std::fil
LinuxDesktopEntry entry;
entry.Icon = readPng(iconSource);
const auto name = title(sceSysDirectory / "param.json", executablePath);
entry.IconPath = std::filesystem::path(executablePath.string() + ".png");
entry.EntryPath = std::filesystem::path(executablePath.string() + ".desktop");
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(executablePath.string()) + "\n"
"Path=" + escapeValue(executablePath.parent_path().string()) + "\n"
"Icon=" + escapeValue(entry.IconPath.string()) + "\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;
@ -113,16 +116,16 @@ std::filesystem::path LinuxDesktopEntryWriter::Write(const LinuxDesktopEntry& en
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 '" + entry.IconPath.string() + "'");
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 '" + entry.EntryPath.string() + "'");
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 '" + entry.EntryPath.string() + "' executable: " + error.message());
if (error) throw Domain::RelinkerException("Cannot mark desktop entry '" + Io::Utf8Path(entry.EntryPath) + "' executable: " + error.message());
return entry.EntryPath;
}

View File

@ -113,7 +113,7 @@ int Run(const int argc, char* argv[]) {
}
const Elfpatcher::Linux::LinuxDesktopEntryWriter desktopEntryWriter;
const auto desktopEntry = args.toWindows ? std::nullopt : desktopEntryWriter.Prepare(std::filesystem::path(args.inputPath).parent_path() / "sce_sys", absPath);
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);
@ -158,7 +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: " << desktopEntryWriter.Write(*desktopEntry).string() << '\n';
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()