diff --git a/core/relinker/CMakeLists.txt b/core/relinker/CMakeLists.txt index e914315a1..4561113eb 100644 --- a/core/relinker/CMakeLists.txt +++ b/core/relinker/CMakeLists.txt @@ -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 $) 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/src/linux/LinuxDesktopEntryWriter.cpp b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp index 8dbe77091..04f3d5f91 100644 --- a/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp +++ b/core/relinker/elfpatcher/src/linux/LinuxDesktopEntryWriter.cpp @@ -1,5 +1,6 @@ #include #include +#include #include "prx/libkernel/AppMetadata/include/ParamJsonParser.hpp" #include #include @@ -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 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 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 '" + 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(c); return byte < 0x20 || byte == 0x7f; @@ -96,14 +97,16 @@ std::optional 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(entry.Icon.data()), static_cast(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; } diff --git a/core/relinker/main.cpp b/core/relinker/main.cpp index 0cdb22105..4d13db9f9 100644 --- a/core/relinker/main.cpp +++ b/core/relinker/main.cpp @@ -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" 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()