Files
2026-10-13 20:11:17 +02:00

410 lines
15 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# scripts/deploy-app.sh - publish the packaged app module over FTP.
#
# PS5_HOST=192.168.1.50 ./scripts/deploy-app.sh
# ./scripts/deploy-app.sh --ffpfsc # upload the PFS image instead
# ./scripts/deploy-app.sh --undeploy # remove the staged title (+ image)
# ./scripts/deploy-app.sh --ffpfsc --fresh # FRESH INSTALL: wipe every EVO
# # file first (tools/wipe-evo.py)
#
# Uploads output/app/<TITLE_ID>/ to ftp://<host>:2121/data/homebrew/<TITLE_ID>/.
# Files go up under a temporary name and are renamed into place; eboot.bin and
# sce_sys/param.json are uploaded LAST so a half-finished folder is never
# mountable. After this, mount + launch from the Games row with ShadowMountPlus.
#
# The --ffpfsc image goes up as <TID>.ffpfsc.upload, is read back and
# sha256-compared with the local file, and is renamed into place only on a
# match. A mismatch deletes the upload, so ShadowMount+ never sees bad bytes.
#
# The --ffpfsc deploy also DELETEs /mnt/usb0/{evo.log, evo_status,
# evo_compat_report.txt}, so each launch starts with a fresh log.
#
# This does NOT launch anything - launch safety (never stack launches) is on
# you and ShadowMountPlus. See docs/evo-pro/phase-1b-app-module.md.
# =============================================================================
source "$(dirname "${BASH_SOURCE[0]}")/common.sh"
ACTION="deploy"
FFPFSC=0
FORCE=0
FRESH=0
while (( $# )); do
case "$1" in
--undeploy) ACTION="undeploy" ;;
--ffpfsc) FFPFSC=1 ;;
--force) FORCE=1 ;;
--fresh) FRESH=1 ;;
-h|--help) sed -n '2,16p' "$0"; exit 0 ;;
*) die "unknown option: $1 (try --help)" ;;
esac
shift
done
FTP_PORT="${FTP_PORT:-2121}"
if ! in_container; then
FWD=()
[[ "${ACTION}" == undeploy ]] && FWD+=(--undeploy)
(( FFPFSC )) && FWD+=(--ffpfsc)
(( FORCE )) && FWD+=(--force)
(( FRESH )) && FWD+=(--fresh)
reexec_in_container "deploy-app.sh" "${FWD[@]+"${FWD[@]}"}"
fi
need_cmd python3
require_ps5_host
PARAM="${REPO_ROOT}/projects/evoplayer/sce_sys/param.json"
need_file "${PARAM}"
TITLE_ID="$(python3 -c 'import json,sys,re
t=json.load(open(sys.argv[1]))["titleId"]
sys.exit("bad titleId") if not re.fullmatch(r"PPSA\d{5}",t) else print(t)' "${PARAM}")"
APPDIR="${OUTPUT_DIR}/app/${TITLE_ID}"
FFPFSC_IMG="${OUTPUT_DIR}/app/${TITLE_ID}.ffpfsc"
# --- .ffpfsc image path: one file to /data/homebrew/<TITLE_ID>.ffpfsc --------
#
# Refuse to deploy on top of an EVO that has been launched.
#
# Deploying replaces the .ffpfsc that ShadowMountPlus has MOUNTED and that a
# live process has its code pages mapped from, and the file change then makes
# ShadowMountPlus auto-launch - stacking a second instance on the resident one.
# Either of those can panic the console; both have. Closing from the switcher
# is the only thing that frees the slot, and there is no remote equivalent.
#
# A soft close (Settings > System > Quit) is NOT sufficient here and must not be
# treated as if it were: it parks the app and drains the GPU, which makes the
# subsequent switcher-close safe, but the process stays resident with the image
# still mounted.
#
# Two signals, because one was not enough.
#
# A deploy clears /mnt/usb0, so evo.log is absent until EVO next runs: its
# presence means EVO has been launched since, and may still hold the slot.
# That alone cannot tell "running now" from "ran and was closed", so it blocked
# every deploy after the first and --force became reflex - which is exactly the
# habit you do not want when the dangerous case comes along.
#
# evo_status carries a monotonic `t=`, rewritten every frame by a --usb-remote
# build. Sampling it twice says whether EVO is alive right now:
#
# t advancing RUNNING - the panic case. Refused, and --force does not
# override it; there is no reading of "deploy
# over a live process" that ends well.
# t frozen / absent IDLE - closed, or parked by QUIT with the slot still
# held. Indistinguishable from here, so this is
# where --force still applies.
# evo.log absent FREE - nothing has run since the last deploy.
#
# The IDLE ambiguity is settled by ShadowMount+'s own lifecycle log
# (tools/evo_lifecycle.py slot): "[LINK] runtime layers released: <TID>" after
# the last "[GAME] started: <TID>" means the process is gone - closed from the
# switcher or by `evo-remote.sh close` - and the deploy goes ahead without
# --force. Started-and-not-released means resident (parked counts), refused.
#
check_evo_not_resident() {
local out
out="$(python3 - "${PS5_HOST}" "${FTP_PORT}" <<'PY' 2>/dev/null || echo BROKEN
import sys, time
from ftplib import FTP
def verdict(host, port):
f = FTP()
f.connect(host, port, timeout=10)
f.login()
try:
lines = []
f.cwd("/mnt/usb0")
f.retrlines("LIST", lines.append)
if not any(" evo.log" in l or l.endswith("evo.log") for l in lines):
return "ABSENT"
def stamp():
buf = []
try:
f.retrbinary("RETR /mnt/usb0/evo_status", buf.append)
except Exception:
return None
for tok in b"".join(buf).decode("utf-8", "replace").split():
if tok.startswith("t="):
return tok
return None
first = stamp()
if first is not None:
time.sleep(4)
later = stamp()
if later is not None and later != first:
return "RUNNING"
return "PRESENT"
finally:
try:
f.quit()
except Exception:
pass
try:
print(verdict(sys.argv[1], int(sys.argv[2])))
except Exception:
print("UNREACHABLE")
PY
)"
case "${out}" in
ABSENT) ok "EVO has not run since the last deploy - slot is free" ;;
RUNNING)
die "EVO is RUNNING on ${PS5_HOST} right now - its status heartbeat is advancing.
Deploying now replaces the mounted image under a live process, which has
kernel-panicked this console. --force does not override this.
Close it from the switcher (PS button -> close the application) first." ;;
PRESENT)
local sm
sm="$(PS5_HOST="${PS5_HOST}" FTP_PORT="${FTP_PORT}" TITLE_ID="${TITLE_ID}" \
python3 "${REPO_ROOT}/tools/evo_lifecycle.py" slot 2>/dev/null || true)"
if [[ "${sm}" == FREE ]]; then
ok "EVO ran since the last deploy, and ShadowMount confirms its runtime was released - slot is free"
elif [[ "${sm}" == RESIDENT ]] && (( ! FORCE )); then
die "ShadowMount shows ${TITLE_ID} started and NOT released - it is resident (parked by QUIT, or wedged).
Free it first: pick Quit in the app's side menu, wait for the safe-to-close
screen, then close the app with the PS button."
elif (( FORCE )); then
warn "EVO has been launched since the last deploy - --force given, continuing"
else
die "EVO has run since the last deploy, and is not running now.
One case this cannot see: Settings > System > Quit parks the app but does NOT free
the slot, and a parked EVO looks exactly like a closed one from here. Deploying over a resident EVO replaces the
mounted image under a live process and stacks an auto-launch on top of it -
both have kernel-panicked this console.
Close EVO from the switcher (PS button -> close the application), then
deploy again. Settings > System > Quit parks it and makes that close safe, but
does NOT free the slot on its own.
Override with --force if you know the slot is free."
fi ;;
*)
if (( FORCE )); then
warn "could not check ${PS5_HOST}:${FTP_PORT} for a resident EVO - --force given, continuing"
else
die "could not check ${PS5_HOST}:${FTP_PORT} for a resident EVO.
Not reaching the console is not evidence that the app slot is free, and
this check failing open is how a deploy once landed on top of a running
EVO. Fix the connection, or pass --force if you know the slot is free."
fi ;;
esac
}
if (( FFPFSC )) && [[ "${ACTION}" == "deploy" ]]; then
need_file "${FFPFSC_IMG}" "run ./scripts/package-app.sh --ffpfsc first"
require_ps5_host
check_evo_not_resident
if (( FRESH )); then
# A fresh install: every EVO file goes - the app, /data/ps5twitch
# (settings, logins, playlists, calibration) and EVO's USB files -
# AFTER the resident check above, never over a running EVO.
begin "fresh install: wiping every EVO file on ${PS5_HOST}"
python3 "${REPO_ROOT}/tools/wipe-evo.py" "${PS5_HOST}" --do ||
die "wipe failed - nothing was deployed"
fi
begin "deploy ${TITLE_ID}.ffpfsc -> ftp://${PS5_HOST}:${FTP_PORT}/data/homebrew/"
python3 - "${PS5_HOST}" "${FTP_PORT}" "${TITLE_ID}" "${FFPFSC_IMG}" <<'PY'
import hashlib, sys, time
from ftplib import FTP, error_perm
from posixpath import join
host, port, title_id, img = sys.argv[1:]
root = "/data/homebrew"
remote = join(root, f"{title_id}.ffpfsc")
folder = join(root, title_id)
tmp = remote + ".upload"
def rmtree(ftp, path):
"""ShadowMount+ ignores a .ffpfsc when a same-TITLE_ID folder exists, so
the loose folder must go before the image is served."""
try:
ftp.sendcmd(f"DELE {path}"); return
except error_perm:
pass
try:
prev = ftp.pwd(); ftp.cwd(path)
names = [n for n, _ in ftp.mlsd() if n not in (".", "..")]
ftp.cwd(prev)
except error_perm:
return
for n in names:
rmtree(ftp, join(path, n))
try:
ftp.rmd(path)
except error_perm:
pass
# The runtime files the app module writes - cleared on each deploy so a launch
# always starts fresh (evo-remote.sh / evo-panel read these back).
USB_LOGS = ["evo.log", "evo_status", "evo_compat_report.txt"]
with FTP() as ftp:
ftp.connect(host, int(port), timeout=60)
ftp.login()
try: ftp.set_pasv(True)
except Exception: pass
rmtree(ftp, folder) # kill any stale loose folder for this TID
print(f"cleared {folder} (if present)")
cleared = 0
for name in USB_LOGS:
try: ftp.sendcmd(f"DELE /mnt/usb0/{name}"); cleared += 1
except error_perm: pass
print(f"cleared {cleared} stale /mnt/usb0 log file(s)")
for path in (tmp, remote):
try: ftp.sendcmd(f"DELE {path}")
except error_perm: pass
with open(img, "rb") as fh:
ftp.storbinary(f"STOR {tmp}", fh, blocksize=256 * 1024)
# Read the upload back and hash it BEFORE promoting it: ShadowMount+ acts
# on the final name, so a short or corrupted transfer must never get it.
want = hashlib.sha256()
with open(img, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
want.update(chunk)
got = hashlib.sha256()
ftp.retrbinary(f"RETR {tmp}", got.update, blocksize=256 * 1024)
if got.hexdigest() != want.hexdigest():
try: ftp.sendcmd(f"DELE {tmp}")
except error_perm: pass
sys.exit(f"upload hash MISMATCH - console {got.hexdigest()[:16]}... "
f"!= local {want.hexdigest()[:16]}...; deleted the upload, "
f"nothing was promoted")
print(f"verified sha256 {got.hexdigest()}")
ftp.rename(tmp, remote)
print(f"done: ftp://{host}:{port}{remote}")
PY
# #60: the .ffpfsc is self-contained now - RmlUi's .rml/.rcss/.ttf/.png
# assets are embedded in the binary (evo_rmlui_bundle_data.cpp) instead of
# being read from disk at runtime, so the #44 out-of-band FTP push to
# /data/ps5twitch/app/assets/ is no longer needed. This block used to wipe
# and re-upload that loose tree on every deploy; deleting
# /data/ps5twitch/app/assets/ on the console now has zero effect on the UI.
ok "deploy complete"
echo " Mount + launch from the Games row (ShadowMountPlus). Never stack launches."
exit 0
fi
if [[ "${ACTION}" == "deploy" ]]; then
need_file "${APPDIR}/eboot.bin" "run ./scripts/package-app.sh --probe first"
need_file "${APPDIR}/sce_sys/param.json"
fi
begin "${ACTION} ${TITLE_ID} -> ftp://${PS5_HOST}:${FTP_PORT}/data/homebrew/${TITLE_ID}/"
python3 - "${PS5_HOST}" "${FTP_PORT}" "${TITLE_ID}" "${APPDIR}" "${ACTION}" <<'PY'
import sys, time
from ftplib import FTP, error_perm
from pathlib import Path
from posixpath import join, dirname
host, port, title_id, appdir, action = sys.argv[1:]
root = "/data/homebrew"
target = join(root, title_id)
def code(e): return str(e).split(maxsplit=1)[0]
def ensure_dir(ftp, path):
cur = ""
for part in path.strip("/").split("/"):
cur += "/" + part
try:
ftp.mkd(cur)
except error_perm as e:
if code(e) != "550":
raise
def rm(ftp, path):
"""Recursively remove a file or directory; tolerate absence."""
try:
ftp.sendcmd(f"DELE {path}")
return True
except error_perm as e:
msg = str(e).lower()
if code(e) == "550" and ("no such" in msg or "not found" in msg):
return False
# DELE refused (likely a non-empty directory) - recurse.
try:
prev = ftp.pwd()
ftp.cwd(path)
names = [n for n, _ in ftp.mlsd() if n not in (".", "..")]
ftp.cwd(prev)
except error_perm:
return False
for n in names:
rm(ftp, join(path, n))
try:
ftp.rmd(path)
except error_perm:
pass
return True
def upload_atomic(ftp, local, remote):
d = dirname(remote)
tmp = join(d, "." + remote.rsplit("/", 1)[-1] + ".upload")
ensure_dir(ftp, d)
rm(ftp, tmp)
with open(local, "rb") as fh:
ftp.storbinary(f"STOR {tmp}", fh, blocksize=256 * 1024)
rm(ftp, remote)
ftp.rename(tmp, remote)
with FTP() as ftp:
ftp.connect(host, int(port), timeout=15)
ftp.login()
try:
ftp.set_pasv(True)
except Exception:
pass
if action == "undeploy":
removed = rm(ftp, target)
img_removed = rm(ftp, target + ".ffpfsc")
if removed or img_removed:
print(f"removed {target}{' + .ffpfsc' if img_removed else ''}")
else:
print(f"nothing staged for {title_id}")
sys.exit(0)
app = Path(appdir)
critical = [app / "eboot.bin", app / "sce_sys/param.json"]
files = sorted(p for p in app.rglob("*") if p.is_file() and p not in critical)
files += [p for p in critical if p.is_file()]
print(f"publishing {len(files)} files (eboot.bin + param.json last)")
for i, local in enumerate(files, 1):
rel = local.relative_to(app).as_posix()
print(f" [{i}/{len(files)}] {rel}")
upload_atomic(ftp, local, join(target, rel))
prev = ftp.pwd(); ftp.cwd(target)
listed = {n for n, _ in ftp.mlsd()}
ftp.cwd(prev)
if "eboot.bin" not in listed:
raise SystemExit("upload finished but eboot.bin is not listed")
print(f"done: ftp://{host}:{port}{target}/")
PY
ok "${ACTION} complete"
[[ "${ACTION}" == deploy ]] && echo " Mount + launch from the Games row (ShadowMountPlus). Never stack launches."