"""Funciones extra de PS5 Directo (desde la 1.20). - Payloads: descarga ftpsrv/BFpilot de GitHub y los manda al cargador de ELF (puerto 9021). - Biblioteca: lee param.json, icon0.png y eboot.bin de lo que hay en /data/homebrew. - Firmware: que firmware pide un juego (param.json y el SDK de sus ejecutables). - Sumas de comprobacion (.sfv/.md5/.sha1/.sha256) calculadas mientras se envia. - Diagnostico de red del PC (por que va lento). - Codigo QR para abrir la app desde el movil. - Mantener el PC despierto y apagar/suspender al terminar la cola. - PS5 Dump Forge (https://github.com/quer3q/ps5-dump-forge, GPL-3): se usa su programa de linea de comandos tal cual, como 7-Zip, para crear imagenes .ffpkg/.ffpfsc o empaquetar en LZ4. Solo biblioteca estandar de Python. """ import ftplib import hashlib import io import json import os import posixpath import re import shutil import socket import subprocess import sys import tarfile import threading import time import urllib.error import urllib.parse import urllib.request import zipfile import zlib import engine as E MiB = E.MiB NO_WINDOW = 0x08000000 if os.name == "nt" else 0 def _ua(): return {"User-Agent": "PS5Directo", "Accept": "application/vnd.github+json"} # --------------------------------------------------------------------------- # Codigo QR (modo byte, versiones 1-10). Escrito a mano para no depender de nada. # --------------------------------------------------------------------------- # por nivel: (codewords de correccion por bloque, numero de bloques) para las versiones 1..10 _QR_ECC = { "L": ([7, 10, 15, 20, 26, 18, 20, 24, 30, 18], [1, 1, 1, 1, 1, 2, 2, 2, 2, 4]), "M": ([10, 16, 26, 18, 24, 16, 18, 22, 22, 26], [1, 1, 1, 2, 2, 4, 4, 4, 5, 5]), } _QR_FMT = {"L": 1, "M": 0} _GF_EXP = [0] * 512 _GF_LOG = [0] * 256 _x = 1 for _i in range(255): _GF_EXP[_i] = _x _GF_LOG[_x] = _i _x <<= 1 if _x & 0x100: _x ^= 0x11D for _i in range(255, 512): _GF_EXP[_i] = _GF_EXP[_i - 255] def _gf_mul(a, b): return 0 if a == 0 or b == 0 else _GF_EXP[_GF_LOG[a] + _GF_LOG[b]] def _rs_divisor(degree): res = [0] * (degree - 1) + [1] root = 1 for _ in range(degree): for j in range(degree): res[j] = _gf_mul(res[j], root) if j + 1 < degree: res[j] ^= res[j + 1] root = _gf_mul(root, 2) return res def _rs_remainder(data, divisor): res = [0] * len(divisor) for b in data: factor = b ^ res.pop(0) res.append(0) for i, c in enumerate(divisor): res[i] ^= _gf_mul(c, factor) return res def _qr_raw_modules(ver): r = (16 * ver + 128) * ver + 64 if ver >= 2: na = ver // 7 + 2 r -= (25 * na - 10) * na - 55 if ver >= 7: r -= 36 return r def _qr_data_cw(ver, lvl): ecc, blocks = _QR_ECC[lvl][0][ver - 1], _QR_ECC[lvl][1][ver - 1] return _qr_raw_modules(ver) // 8 - ecc * blocks def _qr_align(ver): if ver == 1: return [] na = ver // 7 + 2 size = ver * 4 + 17 step = (ver * 4 + na * 2 + 1) // (na * 2 - 2) * 2 out = [] pos = size - 7 for _ in range(na - 1): out.insert(0, pos) pos -= step out.insert(0, 6) return out def qr_matrix(text, lvl="M"): """Matriz (lista de filas de bool) del codigo QR de un texto (hasta ~200 bytes).""" data = text.encode("utf-8") ver = None for v in range(1, 11): cc = 8 if v < 10 else 16 if 4 + cc + 8 * len(data) <= _qr_data_cw(v, lvl) * 8: ver = v break if ver is None: raise ValueError("texto demasiado largo para el QR") cap = _qr_data_cw(ver, lvl) bits = [] def put(val, n): for i in range(n - 1, -1, -1): bits.append((val >> i) & 1) put(0b0100, 4) put(len(data), 8 if ver < 10 else 16) for b in data: put(b, 8) put(0, min(4, cap * 8 - len(bits))) put(0, (-len(bits)) % 8) pad = 0xEC while len(bits) < cap * 8: put(pad, 8) pad ^= 0xEC ^ 0x11 cw = [int("".join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)] # bloques y correccion de errores, intercalados ecc_len, nblocks = _QR_ECC[lvl][0][ver - 1], _QR_ECC[lvl][1][ver - 1] raw_cw = _qr_raw_modules(ver) // 8 nshort = nblocks - raw_cw % nblocks short_len = raw_cw // nblocks div = _rs_divisor(ecc_len) blocks = [] k = 0 for i in range(nblocks): dl = short_len - ecc_len + (0 if i < nshort else 1) dat = cw[k:k + dl] k += dl ecc = _rs_remainder(dat, div) if i < nshort: dat = dat + [0] blocks.append(dat + ecc) final = [] for i in range(len(blocks[0])): for j, blk in enumerate(blocks): if i != short_len - ecc_len or j >= nshort: final.append(blk[i]) size = ver * 4 + 17 mod = [[False] * size for _ in range(size)] fn = [[False] * size for _ in range(size)] def setf(x, y, dark): mod[y][x] = dark fn[y][x] = True for i in range(size): setf(6, i, i % 2 == 0) setf(i, 6, i % 2 == 0) for cx, cy in ((3, 3), (size - 4, 3), (3, size - 4)): for dy in range(-4, 5): for dx in range(-4, 5): xx, yy = cx + dx, cy + dy if 0 <= xx < size and 0 <= yy < size: setf(xx, yy, max(abs(dx), abs(dy)) not in (2, 4)) al = _qr_align(ver) n = len(al) for i in range(n): for j in range(n): if (i == 0 and j == 0) or (i == 0 and j == n - 1) or (i == n - 1 and j == 0): continue for dy in range(-2, 3): for dx in range(-2, 3): setf(al[i] + dx, al[j] + dy, max(abs(dx), abs(dy)) != 1) def draw_format(mask): d = _QR_FMT[lvl] << 3 | mask rem = d for _ in range(10): rem = (rem << 1) ^ ((rem >> 9) * 0x537) b = (d << 10 | rem) ^ 0x5412 bit = lambda i: (b >> i) & 1 == 1 # noqa: E731 for i in range(6): setf(8, i, bit(i)) setf(8, 7, bit(6)) setf(8, 8, bit(7)) setf(7, 8, bit(8)) for i in range(9, 15): setf(14 - i, 8, bit(i)) for i in range(8): setf(size - 1 - i, 8, bit(i)) for i in range(8, 15): setf(8, size - 15 + i, bit(i)) setf(8, size - 8, True) draw_format(0) if ver >= 7: rem = ver for _ in range(12): rem = (rem << 1) ^ ((rem >> 11) * 0x1F25) b = ver << 12 | rem for i in range(18): dark = (b >> i) & 1 == 1 a, c = size - 11 + i % 3, i // 3 setf(a, c, dark) setf(c, a, dark) i = 0 right = size - 1 while right >= 1: if right == 6: right = 5 for vert in range(size): for j in range(2): x = right - j upward = ((right + 1) & 2) == 0 y = size - 1 - vert if upward else vert if not fn[y][x] and i < len(final) * 8: mod[y][x] = (final[i >> 3] >> (7 - (i & 7))) & 1 == 1 i += 1 right -= 2 masks = [ lambda x, y: (x + y) % 2 == 0, lambda x, y: y % 2 == 0, lambda x, y: x % 3 == 0, lambda x, y: (x + y) % 3 == 0, lambda x, y: (x // 3 + y // 2) % 2 == 0, lambda x, y: x * y % 2 + x * y % 3 == 0, lambda x, y: (x * y % 2 + x * y % 3) % 2 == 0, lambda x, y: ((x + y) % 2 + x * y % 3) % 2 == 0, ] def apply(m): f = masks[m] for y in range(size): for x in range(size): if not fn[y][x] and f(x, y): mod[y][x] = not mod[y][x] def penalty(): p = 0 lines = [row for row in mod] + [[mod[y][x] for y in range(size)] for x in range(size)] for ln in lines: run, prev = 0, None for v in ln: if v == prev: run += 1 else: if run >= 5: p += 3 + run - 5 run, prev = 1, v if run >= 5: p += 3 + run - 5 s = "".join("1" if v else "0" for v in ln) p += 40 * (s.count("10111010000") + s.count("00001011101")) for y in range(size - 1): for x in range(size - 1): v = mod[y][x] if v == mod[y][x + 1] == mod[y + 1][x] == mod[y + 1][x + 1]: p += 3 dark = sum(sum(1 for v in row if v) for row in mod) total = size * size p += 10 * (abs(dark * 20 - total * 10) // total) return p best, best_p = 0, None for m in range(8): apply(m) draw_format(m) pen = penalty() if best_p is None or pen < best_p: best, best_p = m, pen apply(m) apply(best) draw_format(best) return mod def qr_svg(text, scale=6, border=4): m = qr_matrix(text) n = len(m) path = [] for y, row in enumerate(m): for x, v in enumerate(row): if v: path.append("M%d %dh1v1h-1z" % (x + border, y + border)) dim = n + 2 * border return ('' '' % (dim, dim, dim * scale, dim * scale, "".join(path))) def lan_ip(target=None): """IP del PC en la red local (la que usaria para llegar a la PS5).""" for t in ([target] if target else []) + ["192.168.1.1", "8.8.8.8"]: try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) try: s.connect((t, 9)) ip = s.getsockname()[0] finally: s.close() if ip and not ip.startswith("127."): return ip except Exception: continue return None # --------------------------------------------------------------------------- # Sumas de comprobacion (.sfv / .md5 / .sha1 / .sha256) # --------------------------------------------------------------------------- CHECKSUM_RE = re.compile(r"\.(sfv|md5|sha1|sha256)$", re.I) def is_checksum_name(name): return bool(CHECKSUM_RE.search(name or "")) def parse_checksums(text, fname=""): """{nombre_en_minusculas: (algoritmo, hex)} de un .sfv, .md5, .sha1 o .sha256.""" out = {} ext = (CHECKSUM_RE.search(fname or "") or [None, ""])[1].lower() if fname else "" for line in (text or "").splitlines(): line = line.strip().lstrip("") if not line or line[0] in ";#": continue m = re.match(r"^(MD5|SHA1|SHA256)\s*\((.+)\)\s*=\s*([0-9A-Fa-f]+)$", line) if m: out[posixpath.basename(m.group(2).replace("\\", "/")).lower()] = (m.group(1).lower(), m.group(3).lower()) continue m = re.match(r"^([0-9A-Fa-f]{32}|[0-9A-Fa-f]{40}|[0-9A-Fa-f]{64})\s+\*?(.+)$", line) if m and ext != "sfv": h = m.group(1).lower() algo = {32: "md5", 40: "sha1", 64: "sha256"}[len(h)] out[posixpath.basename(m.group(2).strip().replace("\\", "/")).lower()] = (algo, h) continue m = re.match(r"^(.+?)\s+([0-9A-Fa-f]{8})$", line) if m: out[posixpath.basename(m.group(1).strip().replace("\\", "/")).lower()] = ("crc32", m.group(2).lower()) return out class StreamHasher: """Calcula la suma de un archivo mientras se envia, aunque el envio se reanude. Los reintentos vuelven a pedir el flujo desde lo que ya tiene la consola: si es antes de lo ya sumado, esos bytes se saltan (son los mismos); si hay un hueco, ya no se puede comprobar (valid=False).""" def __init__(self, algo, start=0): self.algo = algo self.pos = 0 self.valid = start == 0 self.crc = 0 self.h = None if algo == "crc32" else hashlib.new(algo) def _update(self, b): if self.h is None: self.crc = zlib.crc32(b, self.crc) else: self.h.update(b) def wrap(self, it, offset): skip = 0 if self.valid: if offset > self.pos: self.valid = False else: skip = self.pos - offset for data in it: if self.valid: if skip: if len(data) <= skip: skip -= len(data) yield data continue part = data[skip:] skip = 0 else: part = data self._update(part) self.pos += len(part) yield data def hexdigest(self): return "%08x" % (self.crc & 0xFFFFFFFF) if self.h is None else self.h.hexdigest() # --------------------------------------------------------------------------- # Firmware que pide un juego # --------------------------------------------------------------------------- def bcd(word): """'0x1160000000000000' -> '11.60'; '0x0700...' -> '7.00'. None si no lo es.""" if not isinstance(word, str): return None w = word.strip() if not w.lower().startswith("0x"): return None h = w[2:] width = 8 if len(h) <= 8 else 16 h = h.rjust(width, "0") if len(h) != width or not re.match(r"^[0-9A-Fa-f]+$", h) or not h[:4].isdigit(): return None major = h[:2].lstrip("0") or "0" return "%s.%s" % (major, h[2:4]) def fw_tuple(s): m = re.match(r"^\s*(\d{1,2})[.,](\d{1,2})", str(s or "")) if not m: return None return int(m.group(1)), int(m.group(2).ljust(2, "0")) def fw_ok(console_fw, need_fw): """True si vale, False si pide uno mas nuevo, None si no se sabe.""" a, b = fw_tuple(console_fw), fw_tuple(need_fw) if not a or not b: return None return b <= a def _sdk_fw(sdk): major, minor = (sdk >> 24) & 0xFF, (sdk >> 16) & 0xFF ok = lambda b: (b >> 4) <= 9 and (b & 0xF) <= 9 # noqa: E731 if sdk == 0 or not ok(major) or not ok(minor): return None return "%x.%02x" % (major, minor) _ELF = b"\x7fELF" _SELF_PS5 = b"\x54\x14\xF5\xEE" _SELF_PS4 = b"\x4F\x15\x3D\x1D" _PROCPARAM, _MODPARAM = 0x61000001, 0x61000002 _PROC_MAGIC, _MOD_MAGIC = 0x4942524F, 0x3C13F4BF def exe_firmware(read_range): """Firmware minimo que pide un ejecutable (el SDK de su process/module param), como '7.00'. read_range(offset, n) -> bytes. Mismo formato que leen los backports y PS5 Dump Forge. None si no es un ELF o fSELF legible (por ejemplo, si esta cifrado).""" import struct head = read_range(0, 0x10000) if len(head) < 0x40: return None u16 = lambda a: struct.unpack_from("> 20) & 0xFFFF == seg and s_size == size: at = s_off + (param - off) break if at is not None: break if at is None: return None b = read_range(at, 0x20) if len(b) < 0x14: return None want = _PROC_MAGIC if kind == _PROCPARAM else _MOD_MAGIC for base in (0, 8): if base + 0x10 <= len(b) and struct.unpack_from("= n: _ftp_resync(f) return bytes(buf) def _ftp_resync(f): """Al cortar una descarga a medias algunos servidores mandan una respuesta de mas (426 y luego 226), y la siguiente orden recibiria la respuesta equivocada. Manda un NOOP y lee hasta su 200.""" old = None try: old = f.sock.gettimeout() f.sock.settimeout(5) f.putcmd("NOOP") for _ in range(4): line = f.getmultiline() if line.startswith("200"): break except Exception: pass finally: try: f.sock.settimeout(old) except Exception: pass def title_from_param(text): try: j = json.loads(text) if isinstance(text, str) else text except Exception: m = re.search(r'"titleName"\s*:\s*"([^"]+)"', text or "") return m.group(1) if m else None if not isinstance(j, dict): return None lp = j.get("localizedParameters") or {} for lang in (lp.get("defaultLanguage"), "es-ES", "en-US", "en-GB"): if lang and isinstance(lp.get(lang), dict) and lp[lang].get("titleName"): return lp[lang]["titleName"] for v in lp.values(): if isinstance(v, dict) and v.get("titleName"): return v["titleName"] return j.get("titleName") def param_facts(text): """Lo util de un param.json: titulo, Title ID, version y firmware.""" try: j = json.loads(text) except Exception: j = {} if not isinstance(j, dict): j = {} return { "title": title_from_param(j) if j else title_from_param(text), "tid": (j.get("titleId") or E.title_id(text or "") or None), "version": j.get("contentVersion") or j.get("masterVersion"), "fw_param": bcd(j.get("requiredSystemSoftwareVersion")), "sdk": bcd(j.get("sdkVersion")), } def parse_sfo(data): """param.sfo (PS4): diccionario con sus claves (TITLE, TITLE_ID, APP_VER, SYSTEM_VER, CATEGORY...).""" import struct out = {} if len(data) < 20 or data[:4] != b"\0PSF": return out _, _, key_off, data_off, n = struct.unpack_from("<4sIIII", data, 0) for i in range(min(n, 256)): e = 20 + i * 16 if e + 16 > len(data): break k_off, fmt, ln, _mx, d_off = struct.unpack_from("= 4: out[key] = struct.unpack_from("> 24, (sv >> 16) & 0xFF) cat = str(s.get("CATEGORY") or "") return {"title": s.get("TITLE") or None, "tid": s.get("TITLE_ID") or None, "version": s.get("APP_VER") or s.get("VERSION") or None, "fw_param": fw, "need_fw": fw, "extra": cat in ("ac", "gp", "sd")} def inspect_remote_game(f, path, listing=None): """Datos de una aplicacion en carpeta en la consola, por FTP. listing: entradas de 'path'.""" names = {n: x for n, x in (listing if listing is not None else E.ftp_listdir(f, path))} out = {"has_eboot": "eboot.bin" in names, "backport": "fakelib" in names or "fakelib2" in names, "icon": False, "fw_exe": None} try: sce = {n for n, _ in E.ftp_listdir(f, path + "/sce_sys")} except ftplib.all_errors: sce = set() out["icon"] = "icon0.png" in sce if "param.json" in sce: raw = ftp_read(f, path + "/sce_sys/param.json", 0, 512 * 1024) out.update(param_facts(raw.decode("utf-8-sig", "replace"))) if not out.get("tid"): out["param_err"] = "param.json: %d bytes leídos (%r)" % (len(raw), raw[:60]) elif sce: out["param_err"] = "sce_sys no tiene param.json" if out["has_eboot"]: try: out["fw_exe"] = exe_firmware(lambda off, n: ftp_read(f, path + "/eboot.bin", off, n)) except Exception: out["fw_exe"] = None # con backport manda lo que piden los ejecutables (rebajados); sin el, lo que dice param.json out["need_fw"] = (out.get("fw_exe") if out["backport"] else None) or out.get("fw_param") or out.get("fw_exe") out["extra"] = not out["has_eboot"] and bool(out.get("tid")) # DLC o actualizacion return out # --------------------------------------------------------------------------- # Payloads: descargar de GitHub y mandar al cargador de ELF # --------------------------------------------------------------------------- PAYLOADS = [ {"id": "ftpsrv", "name": "ftpsrv", "repo": "ps5-payload-dev/ftpsrv", "what": "Servidor FTP (puerto 2121): por él se envía todo"}, {"id": "bfpilot", "name": "BFpilot", "repo": "ItsBlurf/BFpilot", "what": "Descomprime, mueve y borra en la consola (puerto 5905)"}, ] def http_get(url, limit=200 * MiB, timeout=60, dest=None, progress=None): req = urllib.request.Request(url, headers={"User-Agent": "PS5Directo"}) with urllib.request.urlopen(req, timeout=timeout) as r: if dest is None: return r.read(limit) got = 0 with open(dest, "wb") as fo: while True: chunk = r.read(MiB) if not chunk: break got += len(chunk) if got > limit: raise E.PipeError("La descarga es demasiado grande") fo.write(chunk) if progress: progress(got) return dest def github_release(repo): """(tag, [assets]) de la ultima version publicada (si solo hay pruebas, la mas nueva).""" base = "https://api.github.com/repos/%s/releases" % repo try: req = urllib.request.Request(base + "/latest", headers=_ua()) with urllib.request.urlopen(req, timeout=20) as r: j = json.load(r) except urllib.error.HTTPError as e: if e.code != 404: raise req = urllib.request.Request(base + "?per_page=10", headers=_ua()) with urllib.request.urlopen(req, timeout=20) as r: lst = [x for x in json.load(r) if not x.get("draft")] if not lst: raise E.PipeError("%s no tiene versiones publicadas" % repo) j = lst[0] return (j.get("tag_name") or "").strip(), j.get("assets") or [] def _pick_elf(names): elfs = [n for n in names if n.lower().endswith((".elf", ".bin"))] pref = [n for n in elfs if "ps5" in n.lower()] or [n for n in elfs if "ps4" not in n.lower()] or elfs return pref[0] if pref else None def fetch_payload(pid, data_dir, log=None): """Ruta local del payload (lo baja de GitHub si hace falta) y su version.""" p = next((x for x in PAYLOADS if x["id"] == pid), None) if not p: raise E.PipeError("Payload desconocido") folder = os.path.join(data_dir, "payloads") os.makedirs(folder, exist_ok=True) cached = sorted((n for n in os.listdir(folder) if n.startswith(pid + "-") and n.endswith(".elf")), key=lambda n: os.path.getmtime(os.path.join(folder, n)), reverse=True) try: tag, assets = github_release(p["repo"]) except Exception as e: if cached: if log: log("Sin conexión con GitHub (%s): uso la copia guardada %s" % (e, cached[0])) return os.path.join(folder, cached[0]), cached[0][len(pid) + 1:-4] raise E.PipeError("No pude consultar GitHub (%s): %s" % (p["repo"], e)) safe_tag = re.sub(r"[^\w.-]", "_", tag or "ultima") dest = os.path.join(folder, "%s-%s.elf" % (pid, safe_tag)) if os.path.isfile(dest) and os.path.getsize(dest) > 0: return dest, tag names = {a.get("name", ""): a.get("browser_download_url") for a in assets} elf = _pick_elf(list(names)) if elf: data = http_get(names[elf], limit=64 * MiB) else: z = next((n for n in names if n.lower().endswith(".zip")), None) if not z: raise E.PipeError("La última versión de %s no trae un .elf" % p["repo"]) zf = zipfile.ZipFile(io.BytesIO(http_get(names[z], limit=128 * MiB))) inner = _pick_elf([n for n in zf.namelist() if not n.endswith("/")]) if not inner: raise E.PipeError("El .zip de %s no trae un .elf" % p["repo"]) data = zf.read(inner) if not data[:4] == b"\x7fELF": raise E.PipeError("Lo descargado de %s no parece un payload (.elf)" % p["repo"]) tmp = dest + ".tmp" with open(tmp, "wb") as fo: fo.write(data) os.replace(tmp, dest) if log: log("Descargado %s %s de GitHub" % (p["name"], tag)) return dest, tag PLDMGR_PORT = 8084 PAYLOAD_RX = {"ftpsrv": re.compile(r"ftpsrv", re.I), "bfpilot": re.compile(r"bfpilot", re.I)} class PayloadManager: """Panel web de Payload Manager (itsPLK/ps5-payload-manager) en la consola, puerto 8084. Es lo que usa casi todo el mundo para cargar payloads: desde el PC se le puede pedir que instale, lance o cierre payloads y que los cargue solos al encender (su lista de autoload). Sus acciones son POST y piden la cabecera X-PLDMGR-Request: 1 (su proteccion anti-CSRF).""" def __init__(self, host, port=PLDMGR_PORT): self.host = E.clean_host(host or "")[0] self.port = int(port or PLDMGR_PORT) def _req(self, method, path, body=None, ctype=None, timeout=8): if not self.host: raise E.PipeError("Falta la IP de la PS5: ponla en Ajustes") url = "http://%s:%d%s" % (self.host, self.port, path) headers = {"User-Agent": "PS5Directo"} if method == "POST": headers["X-PLDMGR-Request"] = "1" if ctype: headers["Content-Type"] = ctype req = urllib.request.Request(url, data=body if method == "POST" else None, method=method, headers=headers) try: with urllib.request.urlopen(req, timeout=timeout) as r: return r.read(8 * MiB) except urllib.error.HTTPError as e: msg = e.read(2000).decode("utf-8", "replace").strip() raise E.PipeError("Payload Manager respondió %d: %s" % (e.code, msg[:200])) def _json(self, path, timeout=8): return json.loads(self._req("GET", path, timeout=timeout).decode("utf-8", "replace") or "{}") def version(self, timeout=3): """Version de Payload Manager, o None si no esta abierto en la consola.""" try: v = self._req("GET", "/version", timeout=timeout).decode("utf-8", "replace").strip() return v or "?" except Exception: return None def payloads(self): return self._json("/list_payloads").get("items") or [] def processes(self): return self._json("/processes_list").get("processes") or [] def config(self): return self._json("/get_config") def find(self, pid, items=None): """El payload instalado que corresponde a 'ftpsrv' o 'bfpilot' (el mas nuevo si hay varios).""" rx = PAYLOAD_RX[pid] items = self.payloads() if items is None else items hits = [x for x in items if rx.search(x.get("filename") or "") or rx.search(x.get("name") or "")] if pid == "ftpsrv": # el ftpsrv "normal" antes que otras versiones hits.sort(key=lambda x: "drakmor" in (x.get("filename") or "").lower()) return hits[0] if hits else None def running(self, pid, procs=None): rx = PAYLOAD_RX[pid] procs = self.processes() if procs is None else procs return [p for p in procs if rx.search(p.get("name") or "")] def launch(self, slot): self._req("POST", "/loadpayload:" + urllib.parse.quote(slot, safe="/:"), timeout=30) def kill(self, pid): self._req("POST", "/process_kill?pid=%d" % int(pid), timeout=15) def upload(self, filename, data): q = urllib.parse.urlencode({"filename": filename, "mode": "new"}) self._req("POST", "/manage:upload?" + q, body=data, ctype="application/octet-stream", timeout=60) def add_autoload(self, slots): """Anade esos payloads a su lista de carga automatica (sin quitar lo que ya tuviera) y la activa.""" cfg = self.config() cur = [x.strip() for x in re.split(r"[,\n]", cfg.get("AUTOLOAD_LIST") or "") if x.strip()] new = cur + [s for s in slots if s and s not in cur] body = json.dumps({"AUTOLOAD_LIST": ",".join(new), "AUTOLOAD_ENABLED": True}).encode() self._req("POST", "/set_config", body=body, ctype="application/json", timeout=15) return new def send_elf(host, port, data, timeout=15): """Manda un payload al cargador de ELF de la consola (elfldr/etaHEN, puerto 9021).""" host = E.clean_host(host)[0] if not host: raise E.PipeError("Falta la IP de la PS5: ponla en Ajustes") try: s = socket.create_connection((host, int(port)), timeout=timeout) except OSError as e: raise E.PipeError("El cargador de payloads no responde en %s:%s (%s). Necesitas el " "jailbreak cargado con un cargador de ELF que acepte la red (etaHEN, " "elfldr). El de Y2JB Autoloader solo acepta la propia consola: con él usa " "Payload Manager." % (host, port, e)) try: s.sendall(data) try: s.shutdown(socket.SHUT_WR) except OSError: pass s.settimeout(3) try: s.recv(1024) except OSError: pass finally: s.close() # --------------------------------------------------------------------------- # PS5 Dump Forge (programa de linea de comandos) # --------------------------------------------------------------------------- FORGE_REPO = "quer3q/ps5-dump-forge" FORGE_FORMATS = { # formato de PS5 Directo -> (argumentos de Forge, extension del resultado o None si es carpeta) "plain": (["--to", "folder"], None), # carpeta normal (deshace imagenes, .fpkg y LZ4) "ffpkg": (["--to", "ffpkg"], ".ffpkg"), "exfat": (["--to", "exfat"], ".exfat"), "ffpfs": (["--to", "ffpfs"], ".ffpfs"), "ffpfsc": (["--to", "ffpfsc"], ".ffpfsc"), "fpkg": (["--to", "pkg"], ".pkg"), # FPKG de depuracion: solo firmware 11.60 o inferior "lz4": (["--to", "lz4"], None), "lz4ffpkg": (["--to", "ffpkg", "--lz4-pack"], ".ffpkg"), } FORGE_STAGES = {"extract": "leyendo el comprimido", "scan": "analizando", "preflight": "comprobando", "measure": "midiendo (LZ4)", "write": "escribiendo", "pack": "empaquetando LZ4", "verify": "verificando", "finalize": "terminando"} def forge_cli_name(): return "ps5-dump-forge.exe" if os.name == "nt" else "ps5-dump-forge" FORGE_NEEDED_FLAGS = ("--to", "--lz4-pack", "--lz4-unpack", "--kraken", "--level") def forge_cli_ok(exe): """Comprueba que una version de Forge sigue entendiendo las opciones que usa PS5 Directo. True = vale, False = le faltan opciones, None = no se ha podido saber.""" import subprocess try: p = subprocess.run([exe, "convert", "--help"], stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60, creationflags=E._NOWIN) except Exception: return None out = p.stdout.decode("utf-8", "replace") if "--to" not in out: return None return all(f in out for f in FORGE_NEEDED_FLAGS) def find_forge(custom, data_dir, skip=()): custom = (custom or "").strip().strip('"') if custom and os.path.isfile(custom): return custom root = os.path.join(data_dir, "tools", "forge") best = None if os.path.isdir(root): for d in os.listdir(root): if d in skip: continue p = os.path.join(root, d, forge_cli_name()) if os.path.isfile(p) and (best is None or os.path.getmtime(p) > os.path.getmtime(best)): best = p return best or shutil.which("ps5-dump-forge") def _forge_asset(assets): import platform mach = platform.machine().lower() arm = "arm" in mach or "aarch64" in mach if os.name == "nt": rx = r"windows-arm64\.zip$" if arm else r"windows-x86-64\.zip$" elif sys.platform == "darwin": rx = r"macos-universal\.zip$" else: rx = r"linux-arm64\.tar\.gz$" if arm else r"linux-x86-64\.tar\.gz$" for a in assets: n = a.get("name", "") if re.search(rx, n, re.I) and "webview2" not in n.lower(): return a return None def download_forge(data_dir, log=None, progress=None, release=None): tag, assets = release or github_release(FORGE_REPO) a = _forge_asset(assets) if not a: raise E.PipeError("La última versión de PS5 Dump Forge no trae descarga para este sistema") safe_tag = re.sub(r"[^\w.-]", "_", tag or "ultima") folder = os.path.join(data_dir, "tools", "forge", safe_tag) exe = os.path.join(folder, forge_cli_name()) if os.path.isfile(exe): return exe, tag os.makedirs(folder, exist_ok=True) tmp = os.path.join(folder, "descarga.tmp") if log: log("Descargando PS5 Dump Forge %s (%s)…" % (tag, a.get("name"))) http_get(a["browser_download_url"], limit=400 * MiB, timeout=120, dest=tmp, progress=progress) want = forge_cli_name() try: if a["name"].lower().endswith(".zip"): with zipfile.ZipFile(tmp) as z: m = next((n for n in z.namelist() if posixpath.basename(n) == want and not n.endswith("/")), None) if not m: raise E.PipeError("El .zip de Forge no trae %s" % want) with z.open(m) as src, open(exe + ".tmp", "wb") as dst: shutil.copyfileobj(src, dst) else: with tarfile.open(tmp) as t: m = next((x for x in t.getmembers() if x.isfile() and posixpath.basename(x.name) == want), None) if not m: raise E.PipeError("El paquete de Forge no trae %s" % want) with t.extractfile(m) as src, open(exe + ".tmp", "wb") as dst: shutil.copyfileobj(src, dst) os.replace(exe + ".tmp", exe) if os.name != "nt": os.chmod(exe, 0o755) finally: try: os.remove(tmp) except OSError: pass return exe, tag def _forge_env(password): env = dict(os.environ) env.pop("FORGE_ARCHIVE_PASSWORD", None) if password: env["FORGE_ARCHIVE_PASSWORD"] = password return env def _run_cancellable(args, env, cancel, on_line=None, timeout=None): """Ejecuta un programa leyendo su salida linea a linea. Devuelve (codigo, salida, errores).""" p = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=env, creationflags=NO_WINDOW) out, err = [], [] def pump_err(): for ln in iter(p.stderr.readline, b""): err.append(ln.decode("utf-8", "replace").rstrip()) del err[:-200] th = threading.Thread(target=pump_err, daemon=True) th.start() stop = threading.Event() def watch(): t0 = time.time() while not stop.wait(0.5): if (cancel is not None and cancel.is_set()) or (timeout and time.time() - t0 > timeout): try: p.terminate() except Exception: pass return threading.Thread(target=watch, daemon=True).start() try: for ln in iter(p.stdout.readline, b""): s = ln.decode("utf-8", "replace").rstrip("\r\n") if on_line: on_line(s) else: out.append(s) code = p.wait() finally: stop.set() th.join(5) if cancel is not None and cancel.is_set(): raise E.Cancelled() return code, out, err def _forge_err(err): lines = [x for x in err if x.strip()] msg = lines[-1] if lines else "error desconocido" return re.sub(r"^ps5-dump-forge:\s*", "", msg) def forge_inspect(exe, src, password, cancel): code, out, err = _run_cancellable([exe, "inspect", src, "--json"], _forge_env(password), cancel) if code != 0: raise E.PipeError(_forge_err(err)) try: return json.loads("\n".join(out)) except ValueError: raise E.PipeError("PS5 Dump Forge respondió algo que no entiendo") FORGE_COMP = { # compresion elegida -> argumentos para .fpkg (kraken) y .ffpfsc (nivel zlib) "fpkg": {"fast": ["--kraken", "fast"], "balanced": ["--kraken", "balanced"], "smallest": ["--kraken", "smallest"], "stored": ["--kraken", "stored"]}, "ffpfsc": {"fast": ["--level", "1"], "balanced": ["--level", "6"], "smallest": ["--level", "9"], "stored": ["--level", "0"]}, } def forge_convert(exe, src, out_path, fmt, password, cancel, on_progress, on_log=None, extra=None): """Convierte con Forge. on_progress(etapa, hecho, total). Devuelve el informe (dict).""" args = [exe, "convert", src] + FORGE_FORMATS[fmt][0] + list(extra or []) + ["-o", out_path] state = {"report": None, "error": None} def on_line(s): try: ev = json.loads(s) except ValueError: return k = ev.get("kind") if k == "progress": on_progress(ev.get("stage") or "", int(ev.get("done") or 0), int(ev.get("total") or 0)) elif k == "log" and on_log: on_log(str(ev.get("line") or "")) elif k == "done": r = ev.get("result") or {} if "Ok" in r: state["report"] = r["Ok"] else: state["error"] = str(r.get("Err") or "falló") code, _, err = _run_cancellable(args, _forge_env(password), cancel, on_line) if state["report"] is not None and code == 0: return state["report"] raise E.PipeError("PS5 Dump Forge: %s" % (state["error"] or _forge_err(err))) # --------------------------------------------------------------------------- # Diagnostico de red: por que va lento # --------------------------------------------------------------------------- def _ps(script, timeout=40): return subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], capture_output=True, timeout=timeout, creationflags=NO_WINDOW) def _ping(ip, n=10): if os.name == "nt": args = ["ping", "-n", str(n), "-w", "1000", ip] else: args = ["ping", "-c", str(n), "-W", "1", "-i", "0.3", ip] try: p = subprocess.run(args, capture_output=True, timeout=n * 2 + 10, creationflags=NO_WINDOW) except Exception: return None txt = p.stdout.decode("cp850" if os.name == "nt" else "utf-8", "replace") loss = re.search(r"(\d+)\s*%\s*(?:packet loss|loss|perdidos|p.rdidas|perdidas)", txt, re.I) avg = re.search(r"(?:Average|Media)\s*=\s*(\d+)\s*ms", txt, re.I) mx = re.search(r"(?:Maximum|M.ximo)\s*=\s*(\d+)\s*ms", txt, re.I) rtt = re.search(r"=\s*([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s*ms", txt) out = {"loss": int(loss.group(1)) if loss else None, "avg": None, "max": None} if avg: out["avg"] = float(avg.group(1)) if mx: out["max"] = float(mx.group(1)) if rtt: out["avg"], out["max"] = float(rtt.group(2)), float(rtt.group(3)) return out VPN_RX = re.compile(r"tap-|tun\b|wireguard|wintun|vpn|openvpn|tailscale|zerotier|fortinet|anyconnect|" r"nordlynx|proton|hamachi|radmin|sonicwall|globalprotect|pangp", re.I) def _wlan_info(): try: p = subprocess.run(["netsh", "wlan", "show", "interfaces"], capture_output=True, timeout=15, creationflags=NO_WINDOW) except Exception: return {} txt = p.stdout.decode("cp850", "replace") info = {} pats = {"signal": r"^\s*(?:Signal|Se.al)\s*:\s*(\d+)", "rx": r"^\s*(?:Receive rate|Velocidad de recepci.n)[^:]*:\s*([\d.,]+)", "tx": r"^\s*(?:Transmit rate|Velocidad de transmisi.n)[^:]*:\s*([\d.,]+)", "radio": r"^\s*(?:Radio type|Tipo de radio)\s*:\s*(.+)$", "band": r"^\s*(?:Band|Banda)\s*:\s*(.+)$", "channel": r"^\s*(?:Channel|Canal)\s*:\s*(\d+)", "ssid": r"^\s*SSID\s*:\s*(.+)$"} for k, rx in pats.items(): m = re.search(rx, txt, re.I | re.M) if m: info[k] = m.group(1).strip() return info def diagnose(ps5_ip, cfg, speed=None, active=0): """Lista de comprobaciones: {name, level (ok/warn/bad/info), detail, fix}.""" items = [] def add(name, level, detail, fix=""): items.append({"name": name, "level": level, "detail": detail, "fix": fix}) ip = E.clean_host(ps5_ip or "")[0] if not ip: add("IP de la PS5", "bad", "No está puesta", "Ponla en Ajustes") return items pc_ip = lan_ip(ip) adapter = None if os.name == "nt": script = ( "$ErrorActionPreference='SilentlyContinue';" "$r=Find-NetRoute -RemoteIPAddress '%s' | Where-Object {$_.InterfaceIndex} | Select-Object -First 1;" "$a=$null;$mtu=$null;if($r){$a=Get-NetAdapter -InterfaceIndex $r.InterfaceIndex;" "$mtu=(Get-NetIPInterface -InterfaceIndex $r.InterfaceIndex -AddressFamily IPv4).NlMtu};" "$up=@(Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object Name,InterfaceDescription,LinkSpeed);" "[pscustomobject]@{name=$a.Name;desc=$a.InterfaceDescription;speed=$a.LinkSpeed;" "media=[string]$a.MediaType;phys=[string]$a.PhysicalMediaType;mtu=$mtu;up=$up} | ConvertTo-Json -Depth 4 -Compress" ) % ip.replace("'", "") try: r = _ps(script) adapter = json.loads(r.stdout.decode("utf-8", "replace").strip() or "null") except Exception: adapter = None if adapter and adapter.get("name"): wifi = "802.11" in (adapter.get("phys") or "") or "wireless" in (adapter.get("media") or "").lower() \ or re.search(r"wi-?fi|wireless|wlan|802\.11", adapter.get("desc") or "", re.I) spd = adapter.get("speed") or "" if wifi: w = _wlan_info() det = "El PC llega a la PS5 por Wi-Fi (%s)" % adapter.get("name") bits = [] if w.get("ssid"): bits.append("red %s" % w["ssid"]) if w.get("band"): bits.append(w["band"]) elif w.get("channel"): bits.append("canal %s" % w["channel"]) if w.get("signal"): bits.append("señal %s%%" % w["signal"]) if w.get("tx"): bits.append("enlace %s Mb/s" % w["tx"]) if bits: det += ": " + ", ".join(bits) add("Conexión del PC", "warn", det, "Con cable de red el PC suele enviar 2-3 veces más rápido: el Wi-Fi tiene que bajar el " "archivo y mandarlo a la PS5 por la misma radio.") try: if w.get("signal") and int(w["signal"]) < 60: add("Señal Wi-Fi", "bad", "Señal del %s%%" % w["signal"], "Acerca el PC al router o usa cable: con poca señal la velocidad cae mucho.") except ValueError: pass band = (w.get("band") or "") + " " + (w.get("radio") or "") ch = int(w["channel"]) if (w.get("channel") or "").isdigit() else None if "2.4" in band or "2,4" in band or (ch is not None and ch <= 14 and not re.search(r"5|6", w.get("band") or "")): add("Banda Wi-Fi", "bad", "Conectado a 2,4 GHz", "Conéctate a la red de 5 GHz del router (suele ser 3-5 veces más rápida).") else: m = re.match(r"([\d.]+)\s*([GMK])bps", spd.replace(",", "."), re.I) mbps = None if m: mbps = float(m.group(1)) * {"G": 1000, "M": 1, "K": 0.001}[m.group(2).upper()] if mbps is not None and mbps <= 100: add("Cable de red", "bad", "El PC va por cable, pero el enlace está a %s" % spd, "A 100 Mb/s el máximo son unos 11 MB/s. Prueba otro cable (Cat5e o mejor) u otro " "puerto del router/switch: debería poner 1 Gbps.") else: add("Conexión del PC", "ok", "Por cable (%s, %s)" % (adapter.get("name"), spd or "?")) vpn = [u for u in (adapter.get("up") or []) if VPN_RX.search((u.get("InterfaceDescription") or "") + " " + (u.get("Name") or ""))] if vpn: add("VPN", "warn", "Hay una VPN o red virtual activa: %s" % ", ".join(u.get("Name") or "?" for u in vpn[:3]), "Si el tráfico a la PS5 o las descargas pasan por la VPN, todo va más lento. Desactívala " "mientras envías o excluye la red local.") if VPN_RX.search((adapter.get("desc") or "") + " " + (adapter.get("name") or "")): add("Ruta hacia la PS5", "bad", "El PC llega a la PS5 a través de %s" % adapter.get("name"), "La red local está yendo por la VPN: desactívala o permite el acceso a la red local.") try: if adapter.get("mtu") and int(adapter["mtu"]) < 1400: add("MTU", "warn", "MTU de %s bytes en esa conexión" % adapter["mtu"], "Una MTU baja (VPN, PPPoE) hace los envíos más lentos en la red local.") except (TypeError, ValueError): pass elif os.name == "nt": add("Conexión del PC", "info", "No pude saber por qué conexión llega el PC a la PS5") else: add("Conexión del PC", "info", "Las comprobaciones de Wi-Fi y cable solo están en Windows") if pc_ip and not ip.startswith("127."): a, b = pc_ip.split(".")[:3], ip.split(".")[:3] if len(b) == 3 and a != b: add("Misma red", "warn", "El PC (%s) y la PS5 (%s) están en redes distintas" % (pc_ip, ip), "Si uno está en la red de invitados o en otro router/repetidor, el tráfico da un rodeo. " "Ponlos en la misma red.") pg = _ping(ip) if pg is None or pg.get("avg") is None: add("Ping a la PS5", "bad" if pg and pg.get("loss") == 100 else "info", "La PS5 no responde al ping" if pg and pg.get("loss") == 100 else "No pude medir el ping", "Comprueba la IP y que la PS5 esté encendida y en la misma red." if pg and pg.get("loss") == 100 else "") else: lvl = "ok" fix = "" det = "Media %.0f ms, máximo %.0f ms, %s%% perdidos" % (pg["avg"], pg.get("max") or pg["avg"], pg.get("loss") or 0) if (pg.get("loss") or 0) > 0: lvl, fix = "bad", "Se pierden paquetes: Wi-Fi saturado o con interferencias. Prueba cable o 5 GHz en la PS5." elif pg["avg"] > 15 or (pg.get("max") or 0) > 80: lvl, fix = "warn", ("Latencia alta para una red de casa: suele ser la PS5 por Wi-Fi lejos del router. " "Con cable en la PS5 se nota mucho.") add("Ping a la PS5", lvl, det, fix) lim = float(cfg.get("limit_mbps") or 0) if lim > 0: add("Límite de velocidad", "warn", "Tienes puesto un límite de %g MB/s" % lim, "Ajustes > Límite (0 = sin límite)") if active: add("Otras transferencias", "info", "Hay %d transferencia(s) en marcha compartiendo la red" % active) if speed: mb = speed.get("up") if mb is not None: add("Último test PC → PS5", "ok" if mb >= 20 else ("warn" if mb >= 6 else "bad"), "%.1f MB/s" % mb, "" if mb >= 20 else "Por debajo de lo normal en red de casa: mira los avisos de arriba.") dl = speed.get("dl") if dl is not None and dl < 5: add("Último test de descarga", "warn", "%.1f MB/s desde el enlace" % dl, "El alojamiento da poca velocidad: no es tu red.") add("FTP de la consola", "info", "Si la PS5 escribe en un USB lento, ese disco limita la velocidad", "Prueba a enviar a /data/homebrew (disco interno) para comparar.") return items # --------------------------------------------------------------------------- # Energia: no dormir mientras hay trabajo y apagar/suspender al acabar # --------------------------------------------------------------------------- _AWAKE = {"proc": None} def keep_awake(on): """Que el PC no se suspenda mientras hay envios. En Linux/Mac se hace con un proceso (systemd-inhibit / caffeinate) que dura mientras haga falta.""" if os.name == "nt": try: import ctypes ES_CONTINUOUS, ES_SYSTEM_REQUIRED = 0x80000000, 0x00000001 ctypes.windll.kernel32.SetThreadExecutionState(ES_CONTINUOUS | (ES_SYSTEM_REQUIRED if on else 0)) return True except Exception: return False p = _AWAKE["proc"] if not on: if p is not None: try: p.terminate() except Exception: pass _AWAKE["proc"] = None return True if p is not None and p.poll() is None: return True if sys.platform == "darwin": cmd = ["caffeinate", "-i", "-w", str(os.getpid())] elif shutil.which("systemd-inhibit"): cmd = ["systemd-inhibit", "--what=sleep:idle", "--who=PS5 Directo", "--why=Enviando a la PS5", "--mode=block", "sleep", "infinity"] else: return False try: _AWAKE["proc"] = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) return True except Exception: return False def power_action(kind): """'sleep' (suspender) o 'shutdown' (apagar). Devuelve un texto de lo hecho.""" if os.name == "nt": if kind == "shutdown": subprocess.Popen(["shutdown", "/s", "/t", "0", "/c", "PS5 Directo: cola terminada"], creationflags=NO_WINDOW) return "Apagando el PC" if kind == "sleep": subprocess.Popen(["rundll32.exe", "powrprof.dll,SetSuspendState", "0,1,0"], creationflags=NO_WINDOW) return "Suspendiendo el PC" return "" keep_awake(False) if sys.platform == "darwin": cmd = {"shutdown": ["osascript", "-e", 'tell application "System Events" to shut down'], "sleep": ["pmset", "sleepnow"]}.get(kind) else: cmd = {"shutdown": ["systemctl", "poweroff"], "sleep": ["systemctl", "suspend"]}.get(kind) if not cmd: return "" try: subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: return "No pude: %s" % e return "Apagando el PC" if kind == "shutdown" else "Suspendiendo el PC" def open_path(path): """Abre una carpeta o archivo del PC con el programa del sistema (Explorador, Finder...).""" if os.name == "nt": os.startfile(path) return cmd = ["open", path] if sys.platform == "darwin" else ["xdg-open", path] subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) def pick_native(kind, title): """Selector de archivos/carpeta del sistema en Linux (zenity/kdialog) o Mac (osascript). Devuelve la lista elegida ([] si se cancela) o None si no hay ninguno disponible.""" title = title or "PS5 Directo" if sys.platform == "darwin": if kind == "folder": script = 'POSIX path of (choose folder with prompt %s)' % _as_str(title) else: script = ('set l to choose file with prompt %s with multiple selections allowed\n' 'set o to ""\nrepeat with f in l\nset o to o & POSIX path of f & linefeed\nend repeat\nreturn o' % _as_str(title)) args = ["osascript"] for ln in script.split("\n"): args += ["-e", ln] p = subprocess.run(args, capture_output=True, timeout=3600) if p.returncode != 0: return [] return [x.rstrip("/") or "/" for x in p.stdout.decode("utf-8", "replace").splitlines() if x.strip()] if shutil.which("zenity"): cmd = ["zenity", "--file-selection", "--title=" + title, "--separator=\n"] cmd += ["--directory"] if kind == "folder" else ["--multiple"] elif shutil.which("kdialog"): home = os.path.expanduser("~") cmd = (["kdialog", "--title", title, "--getexistingdirectory", home] if kind == "folder" else ["kdialog", "--title", title, "--getopenfilename", home, "--multiple", "--separate-output"]) else: return None p = subprocess.run(cmd, capture_output=True, timeout=3600) if p.returncode != 0: return [] return [x for x in p.stdout.decode("utf-8", "replace").splitlines() if x.strip()] def _as_str(t): return '"' + str(t).replace("\\", "\\\\").replace('"', '\\"') + '"' # --------------------------------------------------------------------------- # Elegir archivos del PC (Windows sin tkinter) # --------------------------------------------------------------------------- def pick_windows(kind, title): t = (title or "").replace("'", "''") if kind == "folder": body = ("$d=New-Object System.Windows.Forms.FolderBrowserDialog;$d.Description='%s';" "$d.ShowNewFolderButton=$false;if($d.ShowDialog($f) -eq 'OK'){$out=@($d.SelectedPath)}") % t else: body = ("$d=New-Object System.Windows.Forms.OpenFileDialog;$d.Title='%s';$d.Multiselect=$true;" "if($d.ShowDialog($f) -eq 'OK'){$out=@($d.FileNames)}") % t script = ("[Console]::OutputEncoding=[Text.Encoding]::UTF8;Add-Type -AssemblyName System.Windows.Forms;" "$f=New-Object System.Windows.Forms.Form -Property @{TopMost=$true;ShowInTaskbar=$false};" "$out=@();" + body + ";ConvertTo-Json -InputObject @($out) -Compress") p = subprocess.run(["powershell", "-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-Command", script], capture_output=True, timeout=3600, creationflags=NO_WINDOW) if p.returncode != 0: raise E.PipeError((p.stderr.decode("utf-8", "replace").strip().splitlines() or ["?"])[-1]) res = json.loads(p.stdout.decode("utf-8", "replace").strip() or "[]") if isinstance(res, str): res = [res] return [os.path.normpath(x) for x in res if x] # --------------------------------------------------------------------------- # Firewall de Windows para abrir la app desde el movil # --------------------------------------------------------------------------- FW_RULE = "PS5 Directo (movil)" def _python_exes(): """python.exe y pythonw.exe con los que corre PS5 Directo (los que Windows puede tener bloqueados).""" out = [] d = os.path.dirname(os.path.abspath(sys.executable)) for n in (os.path.basename(sys.executable), "python.exe", "pythonw.exe"): p = os.path.join(d, n) if os.path.isfile(p) and p not in out: out.append(p) return out def _ps_list(items): return "@(" + ",".join("'%s'" % x.replace("'", "''") for x in items) + ")" _FW_FIND_BLOCKS = ( "$exes=%s;" "$blk=@(Get-NetFirewallApplicationFilter -ErrorAction SilentlyContinue | Where-Object { $p=$_.Program; " "($exes | Where-Object { $_ -ieq $p }).Count -gt 0 } | Get-NetFirewallRule -ErrorAction SilentlyContinue | " "Where-Object { $_.Direction -eq 'Inbound' -and $_.Action -eq 'Block' -and $_.Enabled -eq 'True' });") def firewall_status(port): """{'rule': hay permiso para el puerto, 'blocked': Windows tiene una regla que bloquea Python, 'public': la red esta como Publica}. Las reglas de bloqueo ganan a las de permitir.""" if os.name != "nt": return {"rule": True, "blocked": False, "public": False} script = ("$ErrorActionPreference='SilentlyContinue';" + _FW_FIND_BLOCKS % _ps_list(_python_exes()) + "$r=@(Get-NetFirewallRule -DisplayName '%s' | Where-Object {$_.Enabled -eq 'True'}).Count;" "$p=@(Get-NetConnectionProfile | Where-Object {$_.NetworkCategory -eq 'Public'}).Count;" "[pscustomobject]@{rule=$r;blocked=$blk.Count;public=$p} | ConvertTo-Json -Compress") % FW_RULE try: j = json.loads(_ps(script, timeout=40).stdout.decode("utf-8", "replace").strip() or "{}") return {"rule": int(j.get("rule") or 0) > 0, "blocked": int(j.get("blocked") or 0) > 0, "public": int(j.get("public") or 0) > 0} except Exception: return {"rule": True, "blocked": False, "public": False} def firewall_allow(port): """Con permiso de administrador: quita los bloqueos de Python que pone Windows si se cancela su aviso y crea el permiso de entrada (puerto y programa) en redes privadas y publicas.""" if os.name != "nt": return True import base64 exes = _python_exes() lines = ["$ErrorActionPreference='SilentlyContinue'", _FW_FIND_BLOCKS % _ps_list(exes) + "$blk | Remove-NetFirewallRule", "Get-NetFirewallRule -DisplayName '%s' | Remove-NetFirewallRule" % FW_RULE, "New-NetFirewallRule -DisplayName '%s' -Direction Inbound -Action Allow -Protocol TCP -LocalPort %d " "-Profile Private,Public | Out-Null" % (FW_RULE, int(port))] for e in exes: lines.append("New-NetFirewallRule -DisplayName '%s' -Direction Inbound -Action Allow -Program '%s' " "-Profile Private,Public | Out-Null" % (FW_RULE, e.replace("'", "''"))) enc = base64.b64encode(";".join(lines).encode("utf-16-le")).decode() script = ("Start-Process -FilePath powershell.exe -Verb RunAs -WindowStyle Hidden -Wait " "-ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-EncodedCommand','%s'" % enc) try: _ps(script, timeout=180) except Exception: pass st = firewall_status(port) return st["rule"] and not st["blocked"]