Install native games as Ports, not ROMs
The store could only offer what an emulator boots, which meant cartridges and nothing else — a little under half the catalog. Batocera runs Linux and its Ports system takes .sh launchers, so a native build can be installed too; it just is not a ROM. `install: "port"` unpacks the zip, finds the executable and writes a launcher that cd's into its directory first, because a game loads assets relative to the working directory. Python's extraction drops the executable bit, so it is restored from the mode the archive records. The payload lives under ports/.data/<subfolder>/, whose leading dot is what hides it from EmulationStation, while the launchers sit in a normal subfolder that gets scanned like any other system's. Both carry the store subfolder, so an uninstall or prune cannot reach another store's files — verified by pointing a state entry at a foreign namespace and watching it be refused. Verified end to end on aarch64 against the live catalog: bevydemo installs as a port with an executable launcher and binary, re-syncs without re-downloading 29 MB, and uninstalls taking its 106 MB payload with it. `install` defaults to rom, so existing configs are unaffected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -149,7 +149,7 @@ counts, favourites and scraped media, and two stores can share one gamelist.
|
|||||||
| `catalog.statuses` | `["released", "archived"]` | catalog `status` values to install |
|
| `catalog.statuses` | `["released", "archived"]` | catalog `status` values to install |
|
||||||
| `catalog.owner_id` | `null` | restrict to one publisher (`/api/software?owner_id=`) |
|
| `catalog.owner_id` | `null` | restrict to one publisher (`/api/software?owner_id=`) |
|
||||||
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
|
| `catalog.only` / `catalog.exclude` | `[]` | software-name allow / deny lists |
|
||||||
| `platforms` | c64, tic80 | platform → system, asset kind, extension, enabled; the kind may be per-architecture |
|
| `platforms` | c64, tic80 | platform → system, asset kind, extension, enabled; `install` picks ROM or Port, and the kind may be per-architecture |
|
||||||
| `behavior.prune` | `true` | remove games that left the catalog or the filters |
|
| `behavior.prune` | `true` | remove games that left the catalog or the filters |
|
||||||
| `behavior.timeout` | `30` | HTTP timeout, seconds |
|
| `behavior.timeout` | `30` | HTTP timeout, seconds |
|
||||||
| `behavior.insecure` | `false` | skip TLS verification (self-hosted test instances) |
|
| `behavior.insecure` | `false` | skip TLS verification (self-hosted test instances) |
|
||||||
@@ -172,6 +172,45 @@ launches, so they are not mapped by default. Adding one is a config edit:
|
|||||||
"platforms": { "godot": { "system": "godot", "kind": "linux_x64", "ext": ".zip", "enabled": true } }
|
"platforms": { "godot": { "system": "godot", "kind": "linux_x64", "ext": ".zip", "enabled": true } }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Native games as Ports
|
||||||
|
|
||||||
|
A cartridge is booted by an emulator, so it goes into that system's ROM folder.
|
||||||
|
A native build is a program: Batocera runs Linux, and its **Ports** system takes
|
||||||
|
`.sh` launchers. Set `install` to `port` and point `system` at `ports`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"platforms": {
|
||||||
|
"bevy": {
|
||||||
|
"system": "ports",
|
||||||
|
"install": "port",
|
||||||
|
"kind": { "x86_64": "linux_x64", "aarch64": "linux_arm64" },
|
||||||
|
"ext": ".zip",
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The zip is unpacked and a launcher written:
|
||||||
|
|
||||||
|
```
|
||||||
|
/userdata/roms/ports/<subfolder>/<name>.sh the launcher ES lists
|
||||||
|
/userdata/roms/ports/<subfolder>/images/<name>.png box art
|
||||||
|
/userdata/roms/ports/.data/<subfolder>/<name>/ the unpacked program
|
||||||
|
```
|
||||||
|
|
||||||
|
The payload sits under `.data`, whose leading dot is what hides it from
|
||||||
|
EmulationStation — the launchers themselves are in a normal subfolder, scanned
|
||||||
|
like any other system's. Both paths carry the store's subfolder, so an uninstall
|
||||||
|
or a prune can only ever reach this store's own files.
|
||||||
|
|
||||||
|
The launcher `cd`s into the directory holding the executable before running it:
|
||||||
|
a game loads its `assets/` relative to the working directory. Python's zip
|
||||||
|
extraction drops the executable bit, so it is restored from the archive's
|
||||||
|
recorded mode.
|
||||||
|
|
||||||
|
`install` defaults to `rom`, so a config that does not mention it behaves exactly
|
||||||
|
as before.
|
||||||
|
|
||||||
### Per-architecture assets
|
### Per-architecture assets
|
||||||
|
|
||||||
Batocera runs on x86_64 mini-PCs, on the Raspberry Pi and on the ARM handhelds
|
Batocera runs on x86_64 mini-PCs, on the Raspberry Pi and on the ARM handhelds
|
||||||
|
|||||||
+26
-8
@@ -8,29 +8,47 @@
|
|||||||
"download": "/api/download"
|
"download": "/api/download"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"roms_root": "/userdata/roms",
|
"roms_root": "/userdata/roms",
|
||||||
"subfolder": "example"
|
"subfolder": "example"
|
||||||
},
|
},
|
||||||
|
|
||||||
"emulationstation": {
|
"emulationstation": {
|
||||||
"folder_name": "Example Store",
|
"folder_name": "Example Store",
|
||||||
"restart": true
|
"restart": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"statuses": ["released", "archived"],
|
"statuses": [
|
||||||
|
"released",
|
||||||
|
"archived"
|
||||||
|
],
|
||||||
"owner_id": null,
|
"owner_id": null,
|
||||||
"only": [],
|
"only": [],
|
||||||
"exclude": []
|
"exclude": []
|
||||||
},
|
},
|
||||||
|
|
||||||
"platforms": {
|
"platforms": {
|
||||||
"c64": { "system": "c64", "kind": "cartridge", "ext": ".prg", "enabled": true },
|
"c64": {
|
||||||
"tic80": { "system": "tic80", "kind": "cartridge", "ext": ".tic", "enabled": true }
|
"system": "c64",
|
||||||
|
"kind": "cartridge",
|
||||||
|
"ext": ".prg",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"tic80": {
|
||||||
|
"system": "tic80",
|
||||||
|
"kind": "cartridge",
|
||||||
|
"ext": ".tic",
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"bevy": {
|
||||||
|
"system": "ports",
|
||||||
|
"install": "port",
|
||||||
|
"kind": {
|
||||||
|
"x86_64": "linux_x64",
|
||||||
|
"aarch64": "linux_arm64"
|
||||||
|
},
|
||||||
|
"ext": ".zip",
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"behavior": {
|
"behavior": {
|
||||||
"prune": true,
|
"prune": true,
|
||||||
"timeout": 30,
|
"timeout": 30,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
@@ -26,6 +27,7 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
import zipfile
|
||||||
|
|
||||||
VERSION = "2.0.0"
|
VERSION = "2.0.0"
|
||||||
STATE_VERSION = 2
|
STATE_VERSION = 2
|
||||||
@@ -398,6 +400,7 @@ def select_games(cfg, catalog, available_systems, arch=None):
|
|||||||
"title": sw.get("title") or name,
|
"title": sw.get("title") or name,
|
||||||
"desc": (sw.get("desc") or "").strip(),
|
"desc": (sw.get("desc") or "").strip(),
|
||||||
"author": (sw.get("author") or "").strip(),
|
"author": (sw.get("author") or "").strip(),
|
||||||
|
"install": spec.get("install", "rom"),
|
||||||
"image_url": sw.get("imageUrl"),
|
"image_url": sw.get("imageUrl"),
|
||||||
"releasedate": es_date(release.get("createdAt")),
|
"releasedate": es_date(release.get("createdAt")),
|
||||||
}
|
}
|
||||||
@@ -437,23 +440,123 @@ def rel_image_path(cfg, game, ext):
|
|||||||
return f"{subfolder(cfg)}/images/{game['name']}{ext}"
|
return f"{subfolder(cfg)}/images/{game['name']}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def rel_port_launcher(cfg, game):
|
||||||
|
return f"{subfolder(cfg)}/{game['name']}.sh"
|
||||||
|
|
||||||
|
|
||||||
|
def rel_port_data(cfg, game):
|
||||||
|
# A `.data` pont-prefixe rejti el az ES elol; a store almappaja alatta
|
||||||
|
# tartja kulon az egyes store-ok payloadjat.
|
||||||
|
return f".data/{subfolder(cfg)}/{game['name']}"
|
||||||
|
|
||||||
|
|
||||||
|
def find_executable(root, name):
|
||||||
|
"""A kicsomagolt fa fo binarisa.
|
||||||
|
|
||||||
|
Az unix zip megorzi a futtathato bitet, tehat elsore azt keressuk. Ha
|
||||||
|
tobb jelolt van, a szoftver nevevel egyezo nyer; ha egy sem futtathato,
|
||||||
|
a nevegyezes az utolso esely.
|
||||||
|
"""
|
||||||
|
execs, by_name = [], []
|
||||||
|
for base, _, files in os.walk(root):
|
||||||
|
for f in files:
|
||||||
|
full = os.path.join(base, f)
|
||||||
|
if os.access(full, os.X_OK) and not os.path.isdir(full):
|
||||||
|
execs.append(full)
|
||||||
|
if f == name:
|
||||||
|
by_name.append(full)
|
||||||
|
for candidate in (execs, by_name):
|
||||||
|
if len(candidate) == 1:
|
||||||
|
return candidate[0]
|
||||||
|
exact = [c for c in candidate if os.path.basename(c) == name]
|
||||||
|
if len(exact) == 1:
|
||||||
|
return exact[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def install_port(cfg, game, dry_run=False):
|
||||||
|
"""A zip asset kicsomagolasa + Ports indito. Visszaad (launcher, data) relativ utakat."""
|
||||||
|
base = system_dir(cfg, game["system"])
|
||||||
|
launcher_rel = rel_port_launcher(cfg, game)
|
||||||
|
data_rel = rel_port_data(cfg, game)
|
||||||
|
launcher_abs = os.path.join(base, launcher_rel)
|
||||||
|
data_abs = os.path.join(base, data_rel)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
log(f"would install {game['name']} {game['version']} as a port -> {launcher_rel}")
|
||||||
|
return launcher_rel, data_rel
|
||||||
|
|
||||||
|
url = api_url(cfg, "download", {"path": game["asset"]})
|
||||||
|
os.makedirs(os.path.dirname(launcher_abs), exist_ok=True)
|
||||||
|
tmp_zip = os.path.join(os.path.dirname(launcher_abs), f".{TAG}-{game['name']}.zip")
|
||||||
|
size = http_download(cfg, url, tmp_zip)
|
||||||
|
try:
|
||||||
|
shutil.rmtree(data_abs, ignore_errors=True)
|
||||||
|
os.makedirs(data_abs, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(tmp_zip) as zf:
|
||||||
|
zf.extractall(data_abs)
|
||||||
|
# A zip futtathato bitjeit a Python nem allitja vissza.
|
||||||
|
for info in zf.infolist():
|
||||||
|
mode = info.external_attr >> 16
|
||||||
|
if mode & 0o111:
|
||||||
|
target = os.path.join(data_abs, info.filename)
|
||||||
|
if os.path.isfile(target):
|
||||||
|
os.chmod(target, os.stat(target).st_mode | 0o111)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_zip):
|
||||||
|
os.unlink(tmp_zip)
|
||||||
|
|
||||||
|
exe = find_executable(data_abs, game["name"])
|
||||||
|
if not exe:
|
||||||
|
shutil.rmtree(data_abs, ignore_errors=True)
|
||||||
|
raise IOError(f"no executable found in {game['asset']}")
|
||||||
|
|
||||||
|
# A jatek az assetjeit a munkakonyvtarhoz kepest tolti be, ezert a
|
||||||
|
# binaris konyvtarabol kell inditani.
|
||||||
|
exe_dir = os.path.dirname(os.path.relpath(exe, data_abs))
|
||||||
|
run_dir = os.path.join(data_abs, exe_dir) if exe_dir else data_abs
|
||||||
|
write_atomic(launcher_abs, (
|
||||||
|
"#!/bin/bash\n"
|
||||||
|
f"# {game['title']} — {cfg['store']['name']}\n"
|
||||||
|
f"cd {shlex.quote(run_dir)} || exit 1\n"
|
||||||
|
f"exec ./{os.path.basename(exe)}\n"
|
||||||
|
).encode("utf-8"))
|
||||||
|
os.chmod(launcher_abs, 0o755)
|
||||||
|
log(f"installed {game['name']} {game['version']} ({size} bytes) as a port -> {launcher_rel}")
|
||||||
|
return launcher_rel, data_rel
|
||||||
|
|
||||||
|
|
||||||
def install_game(cfg, game, state_entry, dry_run=False):
|
def install_game(cfg, game, state_entry, dry_run=False):
|
||||||
"""Download the asset + box art if missing. Returns (record, changed)."""
|
"""Download the asset + box art if missing. Returns (record, changed)."""
|
||||||
base = system_dir(cfg, game["system"])
|
base = system_dir(cfg, game["system"])
|
||||||
rom_rel = rel_rom_path(cfg, game)
|
|
||||||
rom_abs = os.path.join(base, rom_rel)
|
|
||||||
changed = False
|
changed = False
|
||||||
|
data_rel = None
|
||||||
|
|
||||||
if os.path.isfile(rom_abs) and os.path.getsize(rom_abs) > 0:
|
if game.get("install") == "port":
|
||||||
debug(f"{game['name']}: {game['asset']} already present")
|
# A port ket dologbol all: egy indito .sh es a kicsomagolt payload.
|
||||||
else:
|
rom_rel = rel_port_launcher(cfg, game)
|
||||||
url = api_url(cfg, "download", {"path": game["asset"]})
|
data_rel = rel_port_data(cfg, game)
|
||||||
if dry_run:
|
installed = (os.path.isfile(os.path.join(base, rom_rel))
|
||||||
log(f"would download {game['name']} {game['version']} -> {rom_abs}")
|
and os.path.isdir(os.path.join(base, data_rel))
|
||||||
|
and state_entry and state_entry.get("asset") == game["asset"])
|
||||||
|
if installed:
|
||||||
|
debug(f"{game['name']}: port already installed")
|
||||||
else:
|
else:
|
||||||
size = http_download(cfg, url, rom_abs)
|
rom_rel, data_rel = install_port(cfg, game, dry_run=dry_run)
|
||||||
log(f"installed {game['name']} {game['version']} ({size} bytes) -> {rom_rel}")
|
changed = True
|
||||||
changed = True
|
else:
|
||||||
|
rom_rel = rel_rom_path(cfg, game)
|
||||||
|
rom_abs = os.path.join(base, rom_rel)
|
||||||
|
if os.path.isfile(rom_abs) and os.path.getsize(rom_abs) > 0:
|
||||||
|
debug(f"{game['name']}: {game['asset']} already present")
|
||||||
|
else:
|
||||||
|
url = api_url(cfg, "download", {"path": game["asset"]})
|
||||||
|
if dry_run:
|
||||||
|
log(f"would download {game['name']} {game['version']} -> {rom_abs}")
|
||||||
|
else:
|
||||||
|
size = http_download(cfg, url, rom_abs)
|
||||||
|
log(f"installed {game['name']} {game['version']} ({size} bytes) -> {rom_rel}")
|
||||||
|
changed = True
|
||||||
|
|
||||||
image_rel = state_entry.get("image") if state_entry else None
|
image_rel = state_entry.get("image") if state_entry else None
|
||||||
if game.get("image_url"):
|
if game.get("image_url"):
|
||||||
@@ -477,6 +580,8 @@ def install_game(cfg, game, state_entry, dry_run=False):
|
|||||||
record = dict(game)
|
record = dict(game)
|
||||||
record["rom"] = rom_rel
|
record["rom"] = rom_rel
|
||||||
record["image"] = image_rel
|
record["image"] = image_rel
|
||||||
|
if data_rel:
|
||||||
|
record["data"] = data_rel
|
||||||
return record, changed
|
return record, changed
|
||||||
|
|
||||||
|
|
||||||
@@ -518,6 +623,19 @@ def remove_game(cfg, record, dry_run=False):
|
|||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
debug(f"removed {path}")
|
debug(f"removed {path}")
|
||||||
|
|
||||||
|
# A port payloadja egy konyvtar a rejtett .data alatt.
|
||||||
|
data_rel = record.get("data")
|
||||||
|
if data_rel:
|
||||||
|
data_abs = os.path.join(base, data_rel)
|
||||||
|
if not data_rel.startswith(f".data/{subfolder(cfg)}/"):
|
||||||
|
log(f"warning: refusing to delete {data_abs} (outside .data/{subfolder(cfg)}/)")
|
||||||
|
elif os.path.isdir(data_abs):
|
||||||
|
if dry_run:
|
||||||
|
log(f"would remove {data_abs}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(data_abs, ignore_errors=True)
|
||||||
|
debug(f"removed {data_abs}")
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# gamelist.xml
|
# gamelist.xml
|
||||||
|
|||||||
Reference in New Issue
Block a user