#!/usr/bin/env python3 """ instalacion.py — Dónde viven los datos cuando Caudal es un programa de escritorio de un solo fichero (el instalable), que lleva dentro su Python. Arrancado desde su carpeta (abrir_app.sh) o en un servidor nada de esto se usa: los datos están donde diga «--datos». El instalable puede guardar en dos sitios, y lo elige quien lo usa la primera vez (hasta que apunte algo, se puede cambiar): · «personal» la carpeta de datos de aplicaciones del usuario. Es lo habitual: el fichero del programa se puede mover o cambiar por una versión nueva sin tocar los datos. · «portatil» una carpeta «caudal-datos» junto al fichero del programa, para llevarlo todo en un USB. En cada arranque: si junto al programa hay una carpeta «caudal-datos», se usa esa; si no, la personal. Dentro de la que sea van «datos/» (la cartera), los ajustes (bitcoin.json, extractos.json…) y «backups/». Como es un fichero suelto, no aparece en el menú de aplicaciones. En Linux lo puede añadir (y quitar) el propio programa, a petición de quien lo usa: un lanzador en la carpeta personal, sin permisos de administrador. """ from __future__ import annotations import json import os import shutil import socket import sys import tarfile import time from pathlib import Path NOMBRE_PORTATIL = "caudal-datos" COPIAS = 30 AJUSTES_EN_COPIA = ("bitcoin.json", "extractos.json", "adjuntos.json") def empaquetado() -> bool: """Cierto cuando esto corre dentro del instalable y no desde el código.""" return bool(getattr(sys, "frozen", False)) def fichero_programa() -> Path: """El fichero del instalable (una AppImage dice el suyo en APPIMAGE).""" return Path(os.environ.get("APPIMAGE") or sys.executable).resolve() def carpeta_programa() -> Path: """La carpeta donde está el fichero del instalable.""" return fichero_programa().parent def base_portatil() -> Path: return carpeta_programa() / NOMBRE_PORTATIL def base_personal() -> Path: if sys.platform == "win32": raiz = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming") return Path(raiz) / "Caudal" if sys.platform == "darwin": return Path.home() / "Library" / "Application Support" / "Caudal" return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") / "caudal" def base_actual() -> tuple[Path, str]: """(carpeta base, «portatil» | «personal») que toca usar en este arranque.""" if base_portatil().is_dir(): return base_portatil(), "portatil" return base_personal(), "personal" def se_puede_escribir_junto_al_programa() -> bool: return os.access(carpeta_programa(), os.W_OK) def mover(modo: str) -> Path: """Lleva la carpeta base al otro sitio y devuelve la nueva. Quien llama ha cerrado antes la cartera. No pisa nada: si en el destino ya hay datos, se niega.""" if modo not in ("portatil", "personal"): raise ValueError("Elige «portatil» o «personal».") origen, actual = base_actual() if modo == actual: return origen destino = base_portatil() if modo == "portatil" else base_personal() if destino.exists() and any(destino.iterdir()): raise ValueError(f"En {destino} ya hay datos: no los toco.") if destino.exists(): destino.rmdir() destino.parent.mkdir(parents=True, exist_ok=True) if origen.exists(): shutil.move(str(origen), str(destino)) else: destino.mkdir(parents=True) return destino def copia_al_abrir(base: Path) -> Path | None: """Como hace abrir_app.sh: una copia de los datos y los ajustes en cada apertura, en «backups/», conservando las últimas. Sin justificantes.""" datos = base / "datos" if not (datos / "activos.jsonl").is_file(): return None carpeta = base / "backups" carpeta.mkdir(parents=True, exist_ok=True) destino = carpeta / f"datos-{time.strftime('%Y%m%d-%H%M%S')}.tar.gz" fuera = {".cerrojo", ".ultima_sesion.json", "adjuntos"} with tarfile.open(destino, "w:gz") as tar: for f in sorted(datos.iterdir()): if f.name not in fuera: tar.add(f, arcname=f"./{f.name}") for nombre in AJUSTES_EN_COPIA: if (base / nombre).is_file(): tar.add(base / nombre, arcname=nombre) for vieja in sorted(carpeta.glob("datos-*.tar.gz"), reverse=True)[COPIAS:]: vieja.unlink(missing_ok=True) return destino # ── Ya está abierto ── # El instalable no tiene ventana propia: se usa desde el navegador. Si se cierra # la pestaña sin pulsar «Salir», sigue en marcha; al abrirlo otra vez hay que # volver a enseñar el que ya está, no decir que la cartera está ocupada. MARCA = ".en_marcha.json" def anotar_en_marcha(base: Path, url: str | None) -> None: """Apunta la dirección con la que se abre este Caudal (o la borra al salir). Lleva la clave de la sesión: solo la lee el propio usuario.""" ruta = Path(base) / MARCA if url is None: ruta.unlink(missing_ok=True) return fd = os.open(ruta, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w", encoding="utf-8") as f: json.dump({"url": url, "pid": os.getpid()}, f) def en_marcha(base: Path) -> str | None: """La dirección del Caudal que ya está abierto sobre estos datos, si contesta.""" try: url = json.loads((Path(base) / MARCA).read_text(encoding="utf-8"))["url"] puerto = int(url.split("//", 1)[1].split("/", 1)[0].rsplit(":", 1)[1]) with socket.create_connection(("127.0.0.1", puerto), timeout=2): return url except (OSError, ValueError, KeyError, IndexError): return None # ── En el menú de aplicaciones (Linux) ── # Un «.desktop» y el icono en la carpeta personal, como manda freedesktop. Lleva # apuntado dónde está el programa: si el fichero se cambia de sitio, al abrirlo # desde su carpeta nueva se corrige solo (ver «repasar_lanzador»). DESCRIPCION = "Tus inversiones y tu bitcoin, llevados en tu casa" MARCA_LANZADOR = "X-Caudal-Programa" def _datos_xdg() -> Path: return Path(os.environ.get("XDG_DATA_HOME") or Path.home() / ".local" / "share") def ruta_lanzador() -> Path: return _datos_xdg() / "applications" / "caudal.desktop" def ruta_icono() -> Path: return _datos_xdg() / "icons" / "hicolor" / "scalable" / "apps" / "caudal.svg" def lanzador_posible() -> bool: return empaquetado() and sys.platform.startswith("linux") def _exec(ruta: Path) -> str: """La ruta como la pide la clave Exec: entre comillas, con sus escapes.""" texto = str(ruta) for c in ("\\", '"', "`", "$"): texto = texto.replace(c, "\\" + c) return '"' + texto.replace("\\", "\\\\").replace("%", "%%") + '"' def programa_del_lanzador() -> Path | None: """A qué fichero apunta el lanzador que hay puesto; None si no hay o no es nuestro.""" try: for linea in ruta_lanzador().read_text(encoding="utf-8").splitlines(): if linea.startswith(MARCA_LANZADOR + "="): return Path(linea.split("=", 1)[1]) except OSError: pass return None def lanzador() -> dict | None: """None donde no se puede; si no, si Caudal está ya en el menú de aplicaciones.""" if not lanzador_posible(): return None return {"puesto": programa_del_lanzador() is not None} def poner_lanzador(icono: Path) -> Path: """Añade Caudal al menú de aplicaciones del usuario (o lo pone al día).""" if not lanzador_posible(): raise ValueError("Aquí no sé añadir Caudal al menú de aplicaciones.") programa = fichero_programa() if "\n" in str(programa): raise ValueError("La carpeta del programa tiene un nombre que el menú no admite.") ruta_icono().parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(icono, ruta_icono()) ruta_lanzador().parent.mkdir(parents=True, exist_ok=True) ruta_lanzador().write_text( "[Desktop Entry]\nType=Application\nName=Caudal\n" f"Comment={DESCRIPCION}\nExec={_exec(programa)}\nIcon={ruta_icono()}\n" "Terminal=false\nCategories=Office;Finance;\nStartupNotify=false\n" f"{MARCA_LANZADOR}={programa}\n", encoding="utf-8") return ruta_lanzador() def quitar_lanzador() -> None: """Quita del menú lo que puso «poner_lanzador»; lo que no sea nuestro no se toca.""" if programa_del_lanzador() is None: return ruta_lanzador().unlink(missing_ok=True) ruta_icono().unlink(missing_ok=True) def repasar_lanzador(icono: Path) -> None: """Al arrancar: si el lanzador apunta a un fichero que ya no existe (el programa se movió o se cambió por otra versión con otro nombre), se apunta a este. Si el otro sigue ahí, no se toca: puede ser otra copia en uso.""" if not lanzador_posible(): return apuntado = programa_del_lanzador() if apuntado is None or apuntado == fichero_programa() or apuntado.exists(): return try: poner_lanzador(icono) except (OSError, ValueError): pass