Fist commit
This commit is contained in:
BIN
scripts/__pycache__/backup_device.cpython-313.pyc
Normal file
BIN
scripts/__pycache__/backup_device.cpython-313.pyc
Normal file
Binary file not shown.
521
scripts/backup_device.py
Normal file
521
scripts/backup_device.py
Normal file
@@ -0,0 +1,521 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only backup helper for the TimbreESP ESP32.
|
||||
|
||||
This script never writes to the ESP32 and never calls erase_flash. It uses the
|
||||
PlatformIO esptool installation to read raw flash regions into a timestamped,
|
||||
private backup directory.
|
||||
|
||||
Default regions (data scope):
|
||||
bootloader 0x01000, 0x08000
|
||||
partitions 0x08000, 0x01000
|
||||
nvs 0x09000, 0x05000
|
||||
otadata 0x0E000, 0x02000
|
||||
littlefs 0x350000, 0xB0000
|
||||
|
||||
Use --scope full to read the complete 4 MiB flash in one pass and then extract
|
||||
all named regions from that image. The full image includes application slots
|
||||
as well as the data regions.
|
||||
|
||||
Examples:
|
||||
python3 scripts/backup_device.py --dry-run
|
||||
python3 scripts/backup_device.py --scope full
|
||||
python3 scripts/backup_device.py --port /dev/cu.usbserial-11220 --scope data
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
REGIONS: tuple[tuple[str, int, int], ...] = (
|
||||
("bootloader.bin", 0x01000, 0x08000),
|
||||
("partitions.bin", 0x08000, 0x01000),
|
||||
("nvs.bin", 0x09000, 0x05000),
|
||||
("otadata.bin", 0x0E000, 0x02000),
|
||||
("littlefs.bin", 0x350000, 0x0B0000),
|
||||
("app0.bin", 0x10000, 0x1A0000),
|
||||
("app1.bin", 0x1B0000, 0x1A0000),
|
||||
)
|
||||
|
||||
FULL_FLASH_SIZE = 0x400000
|
||||
EXPECTED_PARTITIONS = ("nvs", "otadata", "app0", "app1", "spiffs")
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""A recoverable backup failure."""
|
||||
|
||||
|
||||
def first_existing(candidates: Iterable[Path]) -> Path | None:
|
||||
for candidate in candidates:
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def find_pio() -> Path:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".platformio/penv/bin/pio",
|
||||
home / ".platformio/penv/bin/platformio",
|
||||
home / ".platformio/penv/Scripts/platformio.exe",
|
||||
]
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("pio") or shutil.which("platformio")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError(
|
||||
"No se encontro PlatformIO. Abre el proyecto desde VS Code o instala "
|
||||
"PlatformIO Core."
|
||||
)
|
||||
|
||||
|
||||
def find_pio_python() -> Path:
|
||||
home = Path.home()
|
||||
candidates = [
|
||||
home / ".platformio/penv/bin/python",
|
||||
home / ".platformio/penv/bin/python3",
|
||||
home / ".platformio/penv/Scripts/python.exe",
|
||||
]
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("python3") or shutil.which("python")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError("No se encontro un interprete de Python para esptool.")
|
||||
|
||||
|
||||
def find_esptool() -> Path:
|
||||
home = Path.home()
|
||||
package_root = home / ".platformio/packages"
|
||||
candidates = sorted(package_root.glob("tool-esptoolpy*/esptool.py"))
|
||||
found = first_existing(candidates)
|
||||
if found is not None:
|
||||
return found
|
||||
executable = shutil.which("esptool.py")
|
||||
if executable:
|
||||
return Path(executable)
|
||||
raise BackupError("No se encontro tool-esptoolpy dentro de ~/.platformio.")
|
||||
|
||||
|
||||
def find_partition_tool() -> Path | None:
|
||||
home = Path.home()
|
||||
candidates = home.glob(
|
||||
".platformio/packages/framework-arduinoespressif32/tools/gen_esp32part.py"
|
||||
)
|
||||
return first_existing(candidates)
|
||||
|
||||
|
||||
def run_command(
|
||||
command: list[str],
|
||||
*,
|
||||
label: str,
|
||||
log_dir: Path | None = None,
|
||||
capture: bool = False,
|
||||
) -> str:
|
||||
print(f"[{label}] {' '.join(command)}", flush=True)
|
||||
started = time.monotonic()
|
||||
if capture:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
output = result.stdout
|
||||
if result.returncode != 0:
|
||||
print(output, file=sys.stderr)
|
||||
raise BackupError(f"{label} fallo con codigo {result.returncode}")
|
||||
return output
|
||||
|
||||
if log_dir is None:
|
||||
result = subprocess.run(command, check=False)
|
||||
else:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = log_dir / f"{label}.log"
|
||||
with log_path.open("w", encoding="utf-8") as log:
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Ultimas lineas de {log_path}:", file=sys.stderr)
|
||||
try:
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
print("\n".join(lines[-30:]), file=sys.stderr)
|
||||
except OSError:
|
||||
pass
|
||||
raise BackupError(f"{label} fallo con codigo {result.returncode}")
|
||||
|
||||
print(f"[{label}] completado en {time.monotonic() - started:.1f}s", flush=True)
|
||||
return ""
|
||||
|
||||
|
||||
def detect_port(pio: Path) -> str:
|
||||
result = subprocess.run(
|
||||
[str(pio), "device", "list"],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise BackupError(f"No se pudo ejecutar 'pio device list':\n{result.stdout}")
|
||||
ports = re.findall(r"^(/dev/cu\S+)", result.stdout, flags=re.MULTILINE)
|
||||
serial_ports = [
|
||||
port
|
||||
for port in ports
|
||||
if "usbserial" in port.lower() or "wchusbserial" in port.lower()
|
||||
]
|
||||
unique = sorted(set(serial_ports))
|
||||
if not unique:
|
||||
raise BackupError(
|
||||
"No se detecto ningun puerto USB serial. Desconecta/reconecta el "
|
||||
"ESP32 y vuelve a ejecutar el script."
|
||||
)
|
||||
if len(unique) > 1:
|
||||
raise BackupError(
|
||||
"Hay varios puertos USB serial conectados: "
|
||||
+ ", ".join(unique)
|
||||
+ ". Usa --port para indicar el correcto."
|
||||
)
|
||||
return unique[0]
|
||||
|
||||
|
||||
def ensure_port_free(port: str) -> None:
|
||||
port_path = Path(port)
|
||||
if not port_path.exists():
|
||||
raise BackupError(f"El puerto {port} ya no existe. Vuelve a detectar el ESP32.")
|
||||
lsof = shutil.which("lsof")
|
||||
if lsof is None:
|
||||
print("[AVISO] lsof no esta disponible; no se pudo comprobar el bloqueo del puerto.")
|
||||
return
|
||||
result = subprocess.run(
|
||||
[lsof, port],
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
owners = [line for line in result.stdout.splitlines() if line.strip()]
|
||||
if owners:
|
||||
print(port, file=sys.stderr)
|
||||
print("\n".join(owners), file=sys.stderr)
|
||||
raise BackupError(
|
||||
f"El puerto {port} esta ocupado. Cierra el Serial Monitor antes "
|
||||
"de ejecutar la copia de seguridad."
|
||||
)
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_private(path: Path, data: bytes) -> None:
|
||||
path.write_bytes(data)
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def extract_regions(full_image: Path, output_dir: Path) -> list[dict[str, object]]:
|
||||
entries: list[dict[str, object]] = []
|
||||
with full_image.open("rb") as source:
|
||||
for name, offset, size in REGIONS:
|
||||
source.seek(offset)
|
||||
data = source.read(size)
|
||||
if len(data) != size:
|
||||
raise BackupError(
|
||||
f"La imagen completa no contiene {size} bytes para {name} "
|
||||
f"en offset 0x{offset:X}."
|
||||
)
|
||||
target = output_dir / name
|
||||
write_private(target, data)
|
||||
entries.append(
|
||||
{
|
||||
"file": name,
|
||||
"offset": f"0x{offset:06X}",
|
||||
"size": size,
|
||||
"sha256": sha256_file(target),
|
||||
}
|
||||
)
|
||||
print(f"[extract] {name}: {size} bytes", flush=True)
|
||||
return entries
|
||||
|
||||
|
||||
def decode_partitions(python: Path, partition_tool: Path, binary: Path, output: Path) -> str | None:
|
||||
if partition_tool is None:
|
||||
return "gen_esp32part.py no encontrado"
|
||||
command = [str(python), str(partition_tool), "--quiet", str(binary), str(output)]
|
||||
result = subprocess.run(
|
||||
command,
|
||||
check=False,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return result.stdout.strip() or "No se pudo decodificar la tabla de particiones."
|
||||
return None
|
||||
|
||||
|
||||
def make_readme(scope: str, port: str, baud: int, regions: list[dict[str, object]]) -> str:
|
||||
lines = [
|
||||
"TimbreESP backup (read-only)",
|
||||
"===========================",
|
||||
"",
|
||||
f"Scope: {scope}",
|
||||
f"Port: {port}",
|
||||
f"Baud: {baud}",
|
||||
"",
|
||||
"IMPORTANTES:",
|
||||
"- Este directorio contiene datos privado del dispositivo.",
|
||||
"- NVS puede contener credenciales WiFi y datos de autenticacion.",
|
||||
"- No ejecutar erase_flash sobre el ESP32 usando este directorio.",
|
||||
"- Guardar tambien una copia cifrada fuera de la Mac cuando sea posible.",
|
||||
"",
|
||||
"Regiones:",
|
||||
]
|
||||
for region in regions:
|
||||
lines.append(
|
||||
f"- {region['file']}: offset {region['offset']}, {region['size']} bytes"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"La tabla de particiones decodificada esta en partitions.csv.",
|
||||
"Ver manifest.json para hashes SHA-256.",
|
||||
"",
|
||||
"Restore: no se automatiza. Revisar offsets, tabla de particiones y",
|
||||
"tamano de flash antes de escribir cualquier imagen.",
|
||||
]
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--port", help="Puerto serie; si se omite se autodetecta")
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
type=Path,
|
||||
help="Directorio de salida; por defecto Documents/TimbreESPv2-backups/...",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--baud",
|
||||
type=int,
|
||||
default=115200,
|
||||
help="Baud de lectura (por defecto 115200, mas seguro para CH340/hubs)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--scope",
|
||||
choices=("data", "full"),
|
||||
default="data",
|
||||
help="data: regiones criticas; full: imagen completa de 4 MiB",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-apps",
|
||||
action="store_true",
|
||||
help="En scope=data, leer tambien app0.bin y app1.bin",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-stub",
|
||||
action="store_true",
|
||||
help="Usar el lector ROM sin stub; mas lento pero mas robusto con cables CH340",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Mostrar puerto y regiones sin acceder al ESP32",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
os.umask(0o077)
|
||||
output_dir: Path | None = None
|
||||
|
||||
try:
|
||||
pio = find_pio()
|
||||
port = args.port or detect_port(pio)
|
||||
ensure_port_free(port)
|
||||
python = find_pio_python()
|
||||
esptool = find_esptool()
|
||||
partition_tool = find_partition_tool()
|
||||
|
||||
regions = list(REGIONS[:5])
|
||||
if args.include_apps:
|
||||
regions.extend(REGIONS[5:])
|
||||
if args.scope == "full":
|
||||
regions = [("flash-full.bin", 0, FULL_FLASH_SIZE)]
|
||||
|
||||
print(f"Puerto: {port}")
|
||||
print(f"Scope: {args.scope}")
|
||||
print("Regiones:")
|
||||
for name, offset, size in regions:
|
||||
print(f" {name}: 0x{offset:06X}, {size} bytes")
|
||||
if args.dry_run:
|
||||
return 0
|
||||
|
||||
if args.out is None:
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
output_dir = Path.home() / "Documents" / "TimbreESPv2-backups" / f"backup-{stamp}"
|
||||
else:
|
||||
output_dir = args.out.expanduser().resolve()
|
||||
if output_dir.exists() and any(output_dir.iterdir()):
|
||||
raise BackupError(f"El directorio de salida ya existe y no esta vacio: {output_dir}")
|
||||
output_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
log_dir = output_dir / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
|
||||
base = [
|
||||
str(python),
|
||||
str(esptool),
|
||||
"--chip",
|
||||
"esp32",
|
||||
"--port",
|
||||
port,
|
||||
"--baud",
|
||||
str(args.baud),
|
||||
"--before",
|
||||
"default_reset",
|
||||
"--after",
|
||||
"hard_reset",
|
||||
]
|
||||
if args.no_stub:
|
||||
base.append("--no-stub")
|
||||
|
||||
chip_output = run_command(base + ["chip_id"], label="chip_id", capture=True)
|
||||
flash_output = run_command(base + ["flash_id"], label="flash_id", capture=True)
|
||||
(output_dir / "device-info.txt").write_text(
|
||||
chip_output + "\n" + flash_output, encoding="utf-8"
|
||||
)
|
||||
os.chmod(output_dir / "device-info.txt", 0o600)
|
||||
|
||||
region_entries: list[dict[str, object]] = []
|
||||
if args.scope == "full":
|
||||
full_path = output_dir / "flash-full.bin"
|
||||
run_command(
|
||||
base
|
||||
+ [
|
||||
"read_flash",
|
||||
f"0x{regions[0][1]:06X}",
|
||||
f"0x{regions[0][2]:06X}",
|
||||
str(full_path),
|
||||
],
|
||||
label="read-flash-full",
|
||||
log_dir=log_dir,
|
||||
)
|
||||
if full_path.stat().st_size != FULL_FLASH_SIZE:
|
||||
raise BackupError(
|
||||
f"La imagen completa mide {full_path.stat().st_size} bytes, "
|
||||
f"se esperaban {FULL_FLASH_SIZE}."
|
||||
)
|
||||
os.chmod(full_path, 0o600)
|
||||
region_entries = extract_regions(full_path, output_dir)
|
||||
region_entries.append(
|
||||
{
|
||||
"file": "flash-full.bin",
|
||||
"offset": "0x000000",
|
||||
"size": FULL_FLASH_SIZE,
|
||||
"sha256": sha256_file(full_path),
|
||||
}
|
||||
)
|
||||
else:
|
||||
for index, (name, offset, size) in enumerate(regions, start=1):
|
||||
target = output_dir / name
|
||||
run_command(
|
||||
base
|
||||
+ [
|
||||
"read_flash",
|
||||
f"0x{offset:06X}",
|
||||
f"0x{size:06X}",
|
||||
str(target),
|
||||
],
|
||||
label=f"read-{index:02d}-{name.removesuffix('.bin')}",
|
||||
log_dir=log_dir,
|
||||
)
|
||||
if target.stat().st_size != size:
|
||||
raise BackupError(
|
||||
f"{name} mide {target.stat().st_size} bytes, se esperaban {size}."
|
||||
)
|
||||
os.chmod(target, 0o600)
|
||||
region_entries.append(
|
||||
{
|
||||
"file": name,
|
||||
"offset": f"0x{offset:06X}",
|
||||
"size": size,
|
||||
"sha256": sha256_file(target),
|
||||
}
|
||||
)
|
||||
print(f"[verify] {name}: {size} bytes", flush=True)
|
||||
|
||||
partition_binary = output_dir / "partitions.bin"
|
||||
partition_csv = output_dir / "partitions.csv"
|
||||
decode_error = decode_partitions(python, partition_tool, partition_binary, partition_csv)
|
||||
if decode_error is None:
|
||||
os.chmod(partition_csv, 0o600)
|
||||
|
||||
manifest = {
|
||||
"schema": 1,
|
||||
"created_local": dt.datetime.now().astimezone().isoformat(),
|
||||
"created_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||||
"scope": args.scope,
|
||||
"port": port,
|
||||
"baud": args.baud,
|
||||
"chip": "esp32",
|
||||
"read_only": True,
|
||||
"regions": region_entries,
|
||||
"partition_decode_error": decode_error,
|
||||
}
|
||||
manifest_path = output_dir / "manifest.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
os.chmod(manifest_path, 0o600)
|
||||
readme = output_dir / "README.txt"
|
||||
readme.write_text(make_readme(args.scope, port, args.baud, region_entries), encoding="utf-8")
|
||||
os.chmod(readme, 0o600)
|
||||
|
||||
print(f"Backup completada: {output_dir}")
|
||||
print("Manifest: manifest.json")
|
||||
print("No se realizo ninguna escritura en el ESP32.")
|
||||
return 0
|
||||
except (BackupError, OSError, subprocess.SubprocessError) as error:
|
||||
if output_dir is not None and output_dir.exists():
|
||||
try:
|
||||
marker = output_dir / "INCOMPLETE.txt"
|
||||
marker.write_text(
|
||||
"Backup incompleto. No usar este directorio para restaurar.\n"
|
||||
f"Error: {error}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
marker.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
print(f"ERROR: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
96
scripts/embed_ui.py
Executable file
96
scripts/embed_ui.py
Executable file
@@ -0,0 +1,96 @@
|
||||
Import("env") # noqa: F821
|
||||
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
project_dir = Path(env.subst("$PROJECT_DIR")) # noqa: F821
|
||||
ui_dir = project_dir / "ui"
|
||||
generated_dir = project_dir / "src" / "generated"
|
||||
generated_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
npm = shutil.which("npm")
|
||||
if npm and (ui_dir / "node_modules").is_dir():
|
||||
subprocess.run([npm, "run", "build"], cwd=ui_dir, check=True)
|
||||
|
||||
assets = {
|
||||
"indexHtml": ui_dir / "dist" / "index.html",
|
||||
"appCss": ui_dir / "dist" / "styles.min.css",
|
||||
"appJs": ui_dir / "dist" / "app.min.js",
|
||||
}
|
||||
if not all(path.is_file() for path in assets.values()):
|
||||
assets = {
|
||||
"indexHtml": ui_dir / "index.html",
|
||||
"appCss": ui_dir / "styles.css",
|
||||
"appJs": ui_dir / "app.js",
|
||||
}
|
||||
|
||||
missing = [str(path) for path in assets.values() if not path.is_file()]
|
||||
if missing:
|
||||
raise SystemExit(
|
||||
"Missing UI assets: " + ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
def emit_array(name, data):
|
||||
lines = [f"const char {name}[] PROGMEM = {{"]
|
||||
for offset in range(0, len(data), 16):
|
||||
chunk = data[offset : offset + 16]
|
||||
values = ", ".join(f"0x{value:02X}" for value in chunk)
|
||||
lines.append(f" {values},")
|
||||
lines.append(" 0x00")
|
||||
lines.append("};")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def emit_length(name, data):
|
||||
return f"const std::size_t {name}Length = {len(data)}U;"
|
||||
|
||||
|
||||
header_lines = [
|
||||
"#pragma once",
|
||||
"",
|
||||
"#include <Arduino.h>",
|
||||
"#include <pgmspace.h>",
|
||||
"",
|
||||
"#include <cstddef>",
|
||||
"",
|
||||
]
|
||||
source_lines = [
|
||||
"// Generated by scripts/embed_ui.py. Do not edit manually.",
|
||||
"#include \"UiAssets.h\"",
|
||||
"",
|
||||
"#include <cstddef>",
|
||||
"",
|
||||
]
|
||||
|
||||
for name, path in assets.items():
|
||||
data = path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
header_lines.append(f"extern const char {name}[] PROGMEM;")
|
||||
header_lines.append(f"extern const std::size_t {name}Length;")
|
||||
header_lines.append(f"extern const char {name}Sha256[] PROGMEM;")
|
||||
source_lines.extend(
|
||||
[
|
||||
emit_array(name, data),
|
||||
emit_length(name, data),
|
||||
f'const char {name}Sha256[] PROGMEM = "{digest}";',
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
def write_if_changed(path, content):
|
||||
if path.is_file() and path.read_text(encoding="utf-8") == content:
|
||||
return
|
||||
path.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
write_if_changed(generated_dir / "UiAssets.h", "\n".join(header_lines) + "\n")
|
||||
write_if_changed(generated_dir / "UiAssets.cpp", "\n".join(source_lines) + "\n")
|
||||
|
||||
print("Embedded UI assets:")
|
||||
for name, path in assets.items():
|
||||
data = path.read_bytes()
|
||||
print(f" {name}: {path.name} ({len(data)} bytes)")
|
||||
Reference in New Issue
Block a user