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:
parent
ca330dfefb
commit
33a1ca5d33
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue