Diseño para móvil, programa de escritorio para Linux (un solo fichero) y versiones firmadas: manifiesto de huellas, firma PGP y la clave del autor (VERIFICAR.md).
243 lines
9 KiB
Python
243 lines
9 KiB
Python
#!/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
|