mirror of
https://github.com/spoopyghosty0/frameport.git
synced 2026-10-17 17:01:30 +02:00
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Linux apps (GitHub #31; arm64, x86_64 through FEX): detection of AppImages, folders and archives."""
|
|
import tarfile
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from frameport import pipeline
|
|
from frameport.analysis import linux
|
|
|
|
|
|
def elf(machine: int, appimage: bool = False, extra: bytes = b"") -> bytes:
|
|
head = bytearray(64)
|
|
head[:4] = b"\x7fELF"
|
|
head[4], head[5] = 2, 1 # 64-bit, little endian
|
|
if appimage:
|
|
head[8:11] = b"AI\x02"
|
|
head[18:20] = machine.to_bytes(2, "little")
|
|
return bytes(head) + extra
|
|
|
|
|
|
def test_appimage_detection_and_title(tmp_path):
|
|
p = tmp_path / "Venera-Prime-2.4.2-aarch64.AppImage"
|
|
p.write_bytes(elf(linux.EM_AARCH64, appimage=True))
|
|
info = linux.inspect(p)
|
|
assert info["appimage"] and info["arch_ok"] and info["files"] == [p.name] and info["title"] == "Venera Prime"
|
|
assert not info["openxr"]
|
|
|
|
|
|
def test_x86_64_builds_run_through_fex(tmp_path):
|
|
p = tmp_path / "Thing-1.0-x86_64.AppImage"
|
|
p.write_bytes(elf(linux.EM_X86_64, appimage=True))
|
|
g = pipeline.add_linux_app(p)
|
|
extra = g["analysis"]["extra"]
|
|
assert extra["x86_64"] and g["analysis"]["abis"] == ["x86_64"]
|
|
other = tmp_path / "riscv-tool"
|
|
other.write_bytes(elf(243))
|
|
with pytest.raises(ValueError, match="machine 243"):
|
|
pipeline.add_linux_app(other)
|
|
|
|
|
|
def test_arm64_program_wins_over_x86_64(tmp_path):
|
|
root = tmp_path / "Both"
|
|
(root / "x86").mkdir(parents=True)
|
|
(root / "x86" / "Both").write_bytes(elf(linux.EM_X86_64, extra=b"\0" * 4096)) # bigger: ranked first by size
|
|
(root / "Both").write_bytes(elf(linux.EM_AARCH64))
|
|
info = linux.inspect(root)
|
|
assert info["exe"] == "Both" and info["arch_ok"] and info["candidates"] == ["Both"]
|
|
(root / "Both").unlink()
|
|
info = linux.inspect(root)
|
|
assert info["exe"] == "x86/Both" and info["machine"] == linux.EM_X86_64 and not info["arch_ok"]
|
|
|
|
|
|
def test_archive_program_ranking_and_openxr(tmp_path):
|
|
src = tmp_path / "src" / "MatineeVR"
|
|
(src / "lib").mkdir(parents=True)
|
|
(src / "MatineeVR").write_bytes(elf(linux.EM_AARCH64))
|
|
(src / "crashpad_handler").write_bytes(elf(linux.EM_AARCH64))
|
|
(src / "lib" / "libopenxr_loader.so.1").write_bytes(elf(linux.EM_AARCH64, extra=b"xrCreateInstance"))
|
|
(src / "data" / "deep").mkdir(parents=True)
|
|
archive = tmp_path / "MatineeVR-0.8-linux-arm64.tar.gz"
|
|
with tarfile.open(archive, "w:gz") as t:
|
|
t.add(src, arcname="MatineeVR")
|
|
info = linux.inspect(archive)
|
|
assert info["exe"] == "MatineeVR/MatineeVR" and info["openxr"] and info["arch_ok"]
|
|
assert linux.inspect(archive)["root"] == info["root"] # unpacked once
|
|
|
|
|
|
def test_bundled_data_libraries_dont_make_an_app_vr(tmp_path):
|
|
root = tmp_path / "FramePort"
|
|
(root / "data" / "artifacts" / "arm64-v8a").mkdir(parents=True)
|
|
(root / "FramePort").write_bytes(elf(linux.EM_AARCH64))
|
|
(root / "data" / "artifacts" / "arm64-v8a" / "libopenxr_loader.so").write_bytes(b"xrCreateInstance")
|
|
assert not linux.inspect(root)["openxr"]
|
|
|
|
|
|
def test_zip_with_unsafe_paths_is_refused(tmp_path):
|
|
archive = tmp_path / "bad.zip"
|
|
with zipfile.ZipFile(archive, "w") as z:
|
|
z.writestr("../evil", b"x")
|
|
with pytest.raises(ValueError, match="unsafe"):
|
|
linux.inspect(archive)
|
|
|
|
|
|
def test_add_linux_app_library_entry(tmp_path):
|
|
p = tmp_path / "Tool-1.2-aarch64.AppImage"
|
|
p.write_bytes(elf(linux.EM_AARCH64, appimage=True))
|
|
g = pipeline.add_linux_app(p)
|
|
assert g["package"] == "linux.tool" and g["kind"] == "linux" and pipeline.is_linux(g)
|
|
assert g["exe"] == p.name and g["analysis"]["extra"]["appimage"]
|
|
|
|
|
|
def test_local_files_of_a_lone_appimage_never_include_its_folder(tmp_path):
|
|
a, b = tmp_path / "Tool-1.2-aarch64.AppImage", tmp_path / "Other-1.0-aarch64.AppImage"
|
|
a.write_bytes(elf(linux.EM_AARCH64, appimage=True))
|
|
b.write_bytes(elf(linux.EM_AARCH64, appimage=True))
|
|
(tmp_path / "keep.txt").write_text("the user's file")
|
|
g = pipeline.add_linux_app(a)
|
|
pipeline.add_linux_app(b)
|
|
assert g["data_bytes"] == a.stat().st_size
|
|
assert pipeline.local_game_files(g["package"]) == [a] # another AppImage in the same folder doesn't block it
|
|
pipeline.delete_local_files(g["package"])
|
|
assert not a.exists() and b.exists() and (tmp_path / "keep.txt").exists()
|
|
|
|
|
|
def test_change_program_of_a_linux_folder(tmp_path):
|
|
root = tmp_path / "Matinee"
|
|
root.mkdir()
|
|
(root / "Matinee").write_bytes(elf(linux.EM_AARCH64))
|
|
(root / "helper").write_bytes(elf(linux.EM_AARCH64))
|
|
g = pipeline.add_linux_app(root)
|
|
assert g["exe"] == "Matinee" and pipeline.local_game_files(g["package"]) == [root]
|
|
assert pipeline.set_exe(g["package"], "helper")["exe"] == "helper"
|
|
|
|
|
|
def test_linux_recipes_dont_follow_the_catalog(tmp_path, monkeypatch):
|
|
from frameport.core import library
|
|
|
|
p = tmp_path / "Tool-1.2-aarch64.AppImage"
|
|
p.write_bytes(elf(linux.EM_AARCH64, appimage=True))
|
|
g = pipeline.add_linux_app(p)
|
|
monkeypatch.setattr(library, "REFRESH_ON_UPDATE", True)
|
|
data = library.load()
|
|
data["settings"]["recipes.app_version"] = "0.0.0" # as after an app update
|
|
library._follow_catalog(data)
|
|
assert data["games"][g["package"]]["recipe"]["as_is"] is True
|
|
|
|
|
|
def test_failed_pc_game_on_stable_suggests_experimental_proton():
|
|
from frameport.core import library
|
|
|
|
library.upsert_game("rift.x", kind="rift", recipe={"package": "rift.x", "patches": {}},
|
|
installs={"frame": {"result": {"proton": "proton_11-arm64"}}})
|
|
assert pipeline.proton_alternative_worth_trying("rift.x", "fail")
|
|
assert not pipeline.proton_alternative_worth_trying("rift.x", "pass")
|
|
recipe = pipeline.apply_suggestions("rift.x", [pipeline.PROTON_TOOL])
|
|
assert recipe.patches[pipeline.PROTON_TOOL] == {"tool": "proton-experimental"}
|
|
assert not pipeline.proton_alternative_worth_trying("rift.x", "fail") # already on Experimental
|
|
|
|
|
|
# ------------------------------------------------------------------------------------------ the app's own icon (#99)
|
|
def png_bytes(size: int, colour=(200, 40, 40, 255)) -> bytes:
|
|
import io
|
|
|
|
from PIL import Image
|
|
|
|
buf = io.BytesIO()
|
|
Image.new("RGBA", (size, size), colour).save(buf, "PNG")
|
|
return buf.getvalue()
|
|
|
|
|
|
def test_find_icon_in_a_folder_app(tmp_path):
|
|
root = tmp_path / "Tool-1.0-arm64"
|
|
(root / "share/applications").mkdir(parents=True)
|
|
(root / "share/applications/tool.desktop").write_text("[Desktop Entry]\nExec=tool\nIcon=org.tool\n")
|
|
for size in (48, 256):
|
|
d = root / f"share/icons/hicolor/{size}x{size}/apps"
|
|
d.mkdir(parents=True)
|
|
(d / "org.tool.png").write_bytes(png_bytes(size))
|
|
(root / "share/icons/hicolor/scalable/apps").mkdir(parents=True)
|
|
(root / "share/icons/hicolor/scalable/apps/org.tool.svg").write_text("<svg/>")
|
|
assert linux.find_icon(root) == (root / "share/icons/hicolor/256x256/apps/org.tool.png").resolve()
|
|
# nothing outside the app folder, also through a symlink
|
|
outside = tmp_path / "outside.png"
|
|
outside.write_bytes(png_bytes(512))
|
|
for p in list(root.rglob("org.tool.png")):
|
|
p.unlink()
|
|
(root / ".DirIcon").symlink_to(outside)
|
|
assert linux.find_icon(root) is None
|
|
assert linux.find_icon(tmp_path / "missing") is None
|
|
|
|
|
|
def test_add_linux_folder_uses_its_own_icon(tmp_path):
|
|
from frameport.artwork import fetch, sources
|
|
|
|
root = tmp_path / "Tool"
|
|
root.mkdir()
|
|
(root / "tool").write_bytes(elf(linux.EM_AARCH64))
|
|
(root / "tool.desktop").write_text("[Desktop Entry]\nExec=tool\nIcon=tool\n")
|
|
(root / "tool.png").write_bytes(png_bytes(128))
|
|
g = pipeline.add_linux_app(root)
|
|
d = fetch.artwork_dir(g["package"])
|
|
assert (d / "icon.png").exists() and not (d / fetch.PICKED).exists() # automatic: not the user's pick
|
|
assert sources.icon_source(g["package"]) == "app"
|
|
|
|
|
|
def test_app_icon_never_replaces_a_chosen_icon(tmp_path):
|
|
from frameport.artwork import fetch, sources
|
|
|
|
pkg = "linux.tool"
|
|
assert sources.icon_source(pkg) == "generated"
|
|
assert sources.apply_app_icon(pkg, png_bytes(64)) is not None and sources.icon_source(pkg) == "app"
|
|
assert sources.apply_app_icon(pkg, png_bytes(64)) is None # unchanged
|
|
assert sources.apply_app_icon(pkg, png_bytes(64, (0, 0, 255, 255))) is not None # a new version's icon
|
|
assert sources.apply_app_icon(pkg, b"not an image") is None and sources.apply_app_icon(pkg, png_bytes(8)) is None
|
|
custom = tmp_path / "mine.png"
|
|
custom.write_bytes(png_bytes(128, (0, 255, 0, 255)))
|
|
sources.apply_custom(pkg, "icon", custom)
|
|
assert sources.icon_source(pkg) == "custom"
|
|
before = (fetch.artwork_dir(pkg) / "icon.png").read_bytes()
|
|
assert sources.apply_app_icon(pkg, png_bytes(64)) is None
|
|
assert (fetch.artwork_dir(pkg) / "icon.png").read_bytes() == before
|
|
# store art the user picked (with no icon) also keeps the app's icon out of the library
|
|
other = "linux.other"
|
|
(fetch.artwork_dir(other) / fetch.PICKED).write_text("meta")
|
|
assert sources.apply_app_icon(other, png_bytes(64)) is None
|
|
|
|
|
|
def test_install_takes_the_icon_the_frame_found(tmp_path):
|
|
import base64
|
|
|
|
from frameport.artwork import fetch, sources
|
|
from frameport.core import library
|
|
from frameport.core.events import Reporter
|
|
from frameport.install import installer
|
|
|
|
class Frame:
|
|
def __init__(self):
|
|
self.puts, self.runs = [], []
|
|
|
|
def put(self, local, remote, resume=True):
|
|
self.puts.append(remote)
|
|
|
|
def run(self, command, stdin=None):
|
|
self.runs.append(command)
|
|
|
|
pkg = "linux.tool"
|
|
library.upsert_game(pkg, kind="linux", title="Tool")
|
|
frame = Frame()
|
|
result = {"app_icon": {"file": "app-icon.png", "png": base64.b64encode(png_bytes(64)).decode()}}
|
|
installer.apply_app_icon(frame, pkg, "/anchor", result, Reporter())
|
|
assert sources.icon_source(pkg) == "app" and not (fetch.artwork_dir(pkg) / fetch.PICKED).exists()
|
|
assert "/anchor/artwork/icon.png" in frame.puts # the Frame's art set is sent again
|
|
assert frame.runs == ["printf %s 'app' > '/anchor/artwork/.icon-source'"]
|
|
frame.puts.clear()
|
|
installer.apply_app_icon(frame, pkg, "/anchor", result, Reporter()) # unchanged: nothing sent
|
|
installer.apply_app_icon(frame, pkg, "/anchor", {"app_icon": {"file": "app-icon.svg"}}, Reporter())
|
|
assert frame.puts == []
|
|
|
|
|
|
@pytest.mark.parametrize("script", ["install.sh", "setup.sh"])
|
|
@pytest.mark.parametrize("archive", [False, True])
|
|
def test_installer_only_linux_app_refused(tmp_path, script, archive):
|
|
from frameport.errors import explain
|
|
|
|
root = tmp_path / "service"
|
|
root.mkdir()
|
|
(root / script).write_text("#!/bin/sh\necho install\n")
|
|
path = root
|
|
if archive:
|
|
path = tmp_path / "service.tar.gz"
|
|
with tarfile.open(path, "w:gz") as t:
|
|
t.add(root, arcname="service")
|
|
with pytest.raises(ValueError) as caught:
|
|
pipeline.add_linux_app(path)
|
|
message = explain(caught.value)
|
|
assert "FramePort runs programs, not installers" in message
|
|
assert "project's own install instructions" in message
|
|
|
|
|
|
def test_installer_beside_program_does_not_prevent_detection(tmp_path):
|
|
(tmp_path / "install.sh").write_text("#!/bin/sh\n")
|
|
(tmp_path / "program").write_bytes(elf(linux.EM_AARCH64))
|
|
assert linux.inspect(tmp_path)["exe"] == "program"
|
|
|
|
|
|
def test_deep_installer_is_not_used_for_refusal(tmp_path):
|
|
nested = tmp_path / "release" / "scripts"
|
|
nested.mkdir(parents=True)
|
|
(nested / "install.sh").write_text("#!/bin/sh\n")
|
|
with pytest.raises(ValueError, match="no Linux program found"):
|
|
linux.inspect(tmp_path)
|