From c51b7b74ed38078393703f180ade7c9af49aae31 Mon Sep 17 00:00:00 2001 From: Sergio Valor Date: Sat, 30 May 2026 08:52:19 +0200 Subject: [PATCH] =?UTF-8?q?Comprovaci=C3=B3=20d'updates=20a=20l'inici=20op?= =?UTF-8?q?cional=20+=20timeouts=20de=20xarxa=20configurables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Opció marcable «Comprova actualitzacions a l'inici» al menú; persisteix a settings.json i llança la comprovació diferida en obrir la finestra. - Tolerància a repos offline/inalcanzables: low-speed abort + techo dur de temps a les operacions git de xarxa (fetch/clone), evitant cuelgues. - Timeouts exposats a settings.json (git_fetch_timeout, git_clone_timeout, http_timeout, git_stall_limit, git_stall_time) via NetConfig, propagats UI -> workers -> gitops. Co-Authored-By: Claude Opus 4.8 (1M context) --- jlauncher/gitops.py | 136 +++++++++++++++++++++++++++++------- jlauncher/settings.py | 13 ++++ jlauncher/ui/main_window.py | 37 +++++++++- jlauncher/workers.py | 30 ++++++-- 4 files changed, 185 insertions(+), 31 deletions(-) diff --git a/jlauncher/gitops.py b/jlauncher/gitops.py index f49f904..5848ce2 100644 --- a/jlauncher/gitops.py +++ b/jlauncher/gitops.py @@ -14,6 +14,7 @@ import subprocess import urllib.error import urllib.request from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from .config import Game @@ -22,7 +23,26 @@ from .paths import game_dir, metadata_dir, repo_dir LogFn = Callable[[str], None] -_HTTP_TIMEOUT = 15 + +# --- Tolerancia a repos offline / inalcanzables ---------------------------- +# Dos mecanismos complementarios para que git no se cuelgue: +# 1) low-speed abort: si una transferencia baja de `stall_limit` bytes/s durante +# `stall_time` segundos, git la aborta (transferencias que se estancan a media +# descarga). +# 2) techo duro: timeout en subprocess.run que mata git pase lo que pase (cubre +# cuelgues de conexión TCP/DNS cuando el host está offline, donde aún no fluyen +# bytes y el low-speed no llega a dispararse). +# Los valores son configurables desde settings.json (ver settings.Settings). +@dataclass(frozen=True) +class NetConfig: + fetch_timeout: float = 60 # techo para fetch / comprobar update (operación ligera) + clone_timeout: float = 900 # techo para clone (puede traer un repo grande) + http_timeout: float = 15 # techo para la API de Gitea (urllib) + stall_limit: int = 1000 # bytes/s por debajo de los cuales se considera estancado + stall_time: int = 20 # segundos por debajo del límite -> abortar + + +DEFAULT_NET = NetConfig() def _noop(_: str) -> None: @@ -36,13 +56,33 @@ def _auth_args(token: str) -> list[str]: return ["-c", f"http.extraHeader=Authorization: token {token}"] -def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> str: - """Ejecuta git capturando salida; lanza RuntimeError si falla. +def _net_args(net: NetConfig | None) -> list[str]: + """Args -c para abortar transferencias estancadas (solo afectan al transporte http).""" + if net is None: + return [] + return [ + "-c", + f"http.lowSpeedLimit={net.stall_limit}", + "-c", + f"http.lowSpeedTime={net.stall_time}", + ] + + +def _run_git( + args: list[str], + cwd: Path | None, + log: LogFn, + token: str = "", + timeout: float | None = None, + net: NetConfig | None = None, +) -> str: + """Ejecuta git capturando salida; lanza RuntimeError si falla o agota `timeout`. Si se pasa `token`, inyecta la cabecera de autorización de Gitea (para clone/fetch - de repos privados) y la redacta en el log para no filtrarla. + de repos privados) y la redacta en el log para no filtrarla. Si se pasa `net` + añade el low-speed abort; `timeout` impone un techo duro (mata git si se cuelga). """ - cmd = ["git", *_auth_args(token), *args] + cmd = ["git", *_auth_args(token), *_net_args(net), *args] def emit(line: str) -> None: log(line.replace(token, "***") if token else line) @@ -51,13 +91,23 @@ def _run_git(args: list[str], cwd: Path | None, log: LogFn, token: str = "") -> emit("$ " + " ".join(["git", *args])) # GIT_TERMINAL_PROMPT=0: si falta auth en un repo privado, falla rápido # en vez de colgarse esperando credenciales (no hay terminal en la GUI). - proc = subprocess.run( - cmd, - cwd=str(cwd) if cwd else None, - capture_output=True, - text=True, - env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, - ) + try: + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + emit( + f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s): " + "el repositori no respon o no és accessible." + ) + raise RuntimeError( + f"git {' '.join(args)} ha excedit el temps ({timeout:.0f}s)" + ) from None if proc.stdout: emit(proc.stdout.rstrip()) if proc.stderr: @@ -72,7 +122,13 @@ def is_installed(root: Path, game: Game) -> bool: return (repo_dir(root, game.id) / ".git").exists() -def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> bool: +def check_update( + root: Path, + game: Game, + log: LogFn = _noop, + token: str = "", + net: NetConfig = DEFAULT_NET, +) -> bool: """Hace fetch y devuelve True si el clone local está por detrás del remoto. No modifica el árbol de trabajo: solo cuenta los commits de origin/ que no @@ -81,7 +137,14 @@ def check_update(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> repo = repo_dir(root, game.id) if not (repo / ".git").exists(): return False - _run_git(["fetch", "origin", "--prune"], repo, log, token=token) + _run_git( + ["fetch", "origin", "--prune"], + repo, + log, + token=token, + timeout=net.fetch_timeout, + net=net, + ) target = ( load_meta(root, game.id).default_branch or _detect_origin_head(repo, log) @@ -106,7 +169,13 @@ def delete_local(root: Path, game: Game, log: LogFn = _noop) -> None: shutil.rmtree(target, ignore_errors=True) -def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> GameMeta: +def download( + root: Path, + game: Game, + log: LogFn = _noop, + token: str = "", + net: NetConfig = DEFAULT_NET, +) -> GameMeta: """Clona (si no existe) o trae el remoto forzado (descartando cambios locales). Luego refresca la metadata (descripción Gitea + versión + icono) y la devuelve. @@ -114,11 +183,18 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam repo = repo_dir(root, game.id) repo.parent.mkdir(parents=True, exist_ok=True) - branch = _fetch_default_branch(game, log, token) + branch = _fetch_default_branch(game, log, token, net) if (repo / ".git").exists(): log(f"Actualitzant {game.name} (forçat, descartant canvis locals)…") - _run_git(["fetch", "origin", "--prune"], repo, log, token=token) + _run_git( + ["fetch", "origin", "--prune"], + repo, + log, + token=token, + timeout=net.fetch_timeout, + net=net, + ) target = branch or _detect_origin_head(repo, log) or "HEAD" _run_git(["reset", "--hard", f"origin/{target}"], repo, log) _run_git(["clean", "-fd"], repo, log) @@ -126,9 +202,16 @@ def download(root: Path, game: Game, log: LogFn = _noop, token: str = "") -> Gam log(f"Clonant {game.name}…") if repo.exists(): # carpeta a medias sin .git: limpiarla shutil.rmtree(repo, ignore_errors=True) - _run_git(["clone", game.clone_url, str(repo)], None, log, token=token) + _run_git( + ["clone", game.clone_url, str(repo)], + None, + log, + token=token, + timeout=net.clone_timeout, + net=net, + ) - return refresh_metadata(root, game, branch, log, token) + return refresh_metadata(root, game, branch, log, token, net) def refresh_metadata( @@ -137,13 +220,14 @@ def refresh_metadata( branch: str | None = None, log: LogFn = _noop, token: str = "", + net: NetConfig = DEFAULT_NET, ) -> GameMeta: """Reconstruye info.json + icon.png a partir del repo clonado y la API Gitea.""" repo = repo_dir(root, game.id) meta = load_meta(root, game.id) # Descripción + rama por defecto desde la API de Gitea (best-effort). - api = _fetch_gitea_info(game, log, token) + api = _fetch_gitea_info(game, log, token, net) if api is not None: meta.description = api.get("description", meta.description) or meta.description meta.default_branch = api.get("default_branch", meta.default_branch) @@ -194,7 +278,9 @@ def _read_version(game: Game, repo: Path, log: LogFn) -> str: return out.splitlines()[0] if out else "" -def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None: +def _fetch_gitea_info( + game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET +) -> dict | None: if not game.info_url: return None headers = {"Accept": "application/json"} @@ -202,15 +288,17 @@ def _fetch_gitea_info(game: Game, log: LogFn, token: str = "") -> dict | None: headers["Authorization"] = f"token {token}" try: req = urllib.request.Request(game.info_url, headers=headers) - with urllib.request.urlopen(req, timeout=_HTTP_TIMEOUT) as resp: + with urllib.request.urlopen(req, timeout=net.http_timeout) as resp: return json.loads(resp.read().decode("utf-8")) except (urllib.error.URLError, OSError, json.JSONDecodeError, ValueError) as exc: log(f"No s'ha pogut llegir la info de Gitea ({game.info_url}): {exc}") return None -def _fetch_default_branch(game: Game, log: LogFn, token: str = "") -> str | None: - info = _fetch_gitea_info(game, log, token) +def _fetch_default_branch( + game: Game, log: LogFn, token: str = "", net: NetConfig = DEFAULT_NET +) -> str | None: + info = _fetch_gitea_info(game, log, token, net) if info: return info.get("default_branch") return None diff --git a/jlauncher/settings.py b/jlauncher/settings.py index 947f49e..498b20f 100644 --- a/jlauncher/settings.py +++ b/jlauncher/settings.py @@ -16,6 +16,13 @@ class Settings: hide_not_downloaded: bool = False updates_pending: list[str] = field(default_factory=list) # ids con update pendiente gitea_token: str = "" # token personal de Gitea para repos privados (no se versiona) + check_updates_on_start: bool = False # comprobar updates automáticamente al iniciar + # Tolerancia a repos offline/inalcanzables (segundos, salvo stall_limit en bytes/s). + git_fetch_timeout: int = 60 # techo para fetch / comprobar update + git_clone_timeout: int = 900 # techo para clone (repo grande) + http_timeout: int = 15 # techo para la API de Gitea + git_stall_limit: int = 1000 # bytes/s: por debajo se considera transferencia estancada + git_stall_time: int = 20 # segundos estancado antes de abortar def settings_path() -> Path: @@ -34,6 +41,12 @@ def load_settings() -> Settings: hide_not_downloaded=bool(data.get("hide_not_downloaded", False)), updates_pending=list(data.get("updates_pending", [])), gitea_token=str(data.get("gitea_token", "")), + check_updates_on_start=bool(data.get("check_updates_on_start", False)), + git_fetch_timeout=int(data.get("git_fetch_timeout", 60)), + git_clone_timeout=int(data.get("git_clone_timeout", 900)), + http_timeout=int(data.get("http_timeout", 15)), + git_stall_limit=int(data.get("git_stall_limit", 1000)), + git_stall_time=int(data.get("git_stall_time", 20)), ) diff --git a/jlauncher/ui/main_window.py b/jlauncher/ui/main_window.py index 352b583..e8e7ab9 100644 --- a/jlauncher/ui/main_window.py +++ b/jlauncher/ui/main_window.py @@ -4,7 +4,7 @@ from __future__ import annotations from pathlib import Path -from PySide6.QtCore import QThreadPool, Qt +from PySide6.QtCore import QThreadPool, Qt, QTimer from PySide6.QtGui import QAction from PySide6.QtWidgets import ( QInputDialog, @@ -84,6 +84,22 @@ class MainWindow(QMainWindow): self.rows[game_id].set_update_available(True) self._apply_filter() + # Comprobación automática de updates al iniciar (si está activada). Se difiere + # con un timer 0 para que la ventana se muestre antes de lanzar el worker. + if self.settings.check_updates_on_start: + QTimer.singleShot(0, self._check_updates) + + def _net_config(self) -> gitops.NetConfig: + """Construye la config de red/timeouts a partir de las preferencias guardadas.""" + s = self.settings + return gitops.NetConfig( + fetch_timeout=s.git_fetch_timeout, + clone_timeout=s.git_clone_timeout, + http_timeout=s.http_timeout, + stall_limit=s.git_stall_limit, + stall_time=s.git_stall_time, + ) + # --------------------------------------------------------------- menú def _build_menu(self) -> None: @@ -98,6 +114,13 @@ class MainWindow(QMainWindow): self.action_check.triggered.connect(self._check_updates) menu.addAction(self.action_check) + self.action_check_on_start = QAction( + "Comprova actualitzacions a l'inici", self, checkable=True + ) + self.action_check_on_start.setChecked(self.settings.check_updates_on_start) + self.action_check_on_start.toggled.connect(self._on_toggle_check_on_start) + menu.addAction(self.action_check_on_start) + menu.addSeparator() self.action_delete = QAction("Esborra un joc", self, checkable=True) self.action_delete.toggled.connect(self._set_delete_mode) @@ -129,6 +152,10 @@ class MainWindow(QMainWindow): save_settings(self.settings) self._apply_filter() + def _on_toggle_check_on_start(self, checked: bool) -> None: + self.settings.check_updates_on_start = checked + save_settings(self.settings) + def _apply_filter(self) -> None: hide = self.action_hide.isChecked() for row in self.rows.values(): @@ -139,7 +166,9 @@ class MainWindow(QMainWindow): def _check_updates(self) -> None: self.action_check.setEnabled(False) self._log("=== Comprovant actualitzacions ===") - worker = CheckUpdatesWorker(self.root, self.config.games, self.settings.gitea_token) + worker = CheckUpdatesWorker( + self.root, self.config.games, self.settings.gitea_token, self._net_config() + ) worker.signals.log.connect(self._log) worker.signals.result.connect(self._mark_update) worker.signals.finished.connect(self._check_done) @@ -208,7 +237,9 @@ class MainWindow(QMainWindow): row.set_busy(True, "Descarregant…") self._log(f"=== Descàrrega: {game.name} ===") - worker = DownloadWorker(self.root, game, self.settings.gitea_token) + worker = DownloadWorker( + self.root, game, self.settings.gitea_token, self._net_config() + ) worker.signals.log.connect(self._log) worker.signals.finished.connect(lambda _meta, g=game: self._download_done(g)) worker.signals.error.connect(lambda msg, g=game: self._op_error(g, msg)) diff --git a/jlauncher/workers.py b/jlauncher/workers.py index 87689b3..8cd3096 100644 --- a/jlauncher/workers.py +++ b/jlauncher/workers.py @@ -25,17 +25,28 @@ class _Signals(QObject): class DownloadWorker(QRunnable): """Clona o actualiza (forzado) y refresca la metadata de un juego.""" - def __init__(self, root: Path, game: Game, token: str = "") -> None: + def __init__( + self, + root: Path, + game: Game, + token: str = "", + net: gitops.NetConfig = gitops.DEFAULT_NET, + ) -> None: super().__init__() self.root = root self.game = game self.token = token + self.net = net self.signals = _Signals() def run(self) -> None: # noqa: D401 - API de QRunnable try: meta: GameMeta = gitops.download( - self.root, self.game, log=self.signals.log.emit, token=self.token + self.root, + self.game, + log=self.signals.log.emit, + token=self.token, + net=self.net, ) except Exception as exc: # noqa: BLE001 - reportar a la UI self.signals.error.emit(str(exc)) @@ -67,11 +78,18 @@ class CheckUpdatesWorker(QRunnable): Emite `result(game_id, has_update)` por cada juego instalado y `finished` al final. """ - def __init__(self, root: Path, games: list[Game], token: str = "") -> None: + def __init__( + self, + root: Path, + games: list[Game], + token: str = "", + net: gitops.NetConfig = gitops.DEFAULT_NET, + ) -> None: super().__init__() self.root = root self.games = games self.token = token + self.net = net self.signals = _Signals() def run(self) -> None: # noqa: D401 - API de QRunnable @@ -81,7 +99,11 @@ class CheckUpdatesWorker(QRunnable): continue try: has_update = gitops.check_update( - self.root, game, log=self.signals.log.emit, token=self.token + self.root, + game, + log=self.signals.log.emit, + token=self.token, + net=self.net, ) except Exception as exc: # noqa: BLE001 - no abortar el resto self.signals.log.emit(f"check {game.id}: {exc}")