Reported: files corrupt, compress does nothing on hybrid, app closes on full-PQ compress. Root cause: run_async connected plain Python CLOSURES (finish / on_pct / release) to signals emitted from the worker QThread. PySide6 runs a plain-closure slot in the EMITTING thread regardless of the requested connection type — even an explicit Qt.QueuedConnection — because a bare functor has no receiver QObject to give it GUI-thread affinity (verified: such a slot ran on the worker thread; a bound method of a main-thread QObject ran on MAIN). Those closures then called QProgressBar.setValue/setRange/hide, QPushButton.setEnabled and QTextEdit.append from the worker thread: cross-thread QWidget access is undefined behaviour and crashed the app under real X11/Wayland rendering. It only survived offscreen tests (which tolerate the race), which is why prior driver runs passed. The progress-bar work added in 5.1.0 multiplied the cross-thread calls and made the crash reliable; a crash mid-compress also left truncated/corrupt archives. Fix: a _Job(QObject) controller parented to a GUI-thread widget, so it lives in the GUI thread and every slot (on_log/on_pct/on_done/on_finished) is a bound method that Qt auto-marshals to the GUI thread. closeEvent updated to the new _jobs (list of _Job) shape. Verified on real X (window shown, progress bar rendering) with a QProgressBar/ QPushButton instrumentation that flags ANY worker-thread call: BEFORE = setRange/ setValue/hide flagged on the worker thread; AFTER = zero cross-thread calls, and hybrid + full-PQ + password compress/extract all byte-exact round-trip; Verify/ Info/Disk backup+restore/two-concurrent-jobs/close-mid-job all pass.
1153 lines
56 KiB
Python
1153 lines
56 KiB
Python
#!/usr/bin/env python3
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
# Copyright (c) 2025-2026 Cristian Cezar Moisés
|
|
"""VaptVupt GUI — Cross-Platform Post-Quantum Backup.
|
|
|
|
Renamed from "Zupt" in v3.0.0 due to INPI Brasil trademark.
|
|
The .zupt file extension is preserved.
|
|
|
|
Tries PySide6 first (preferred), falls back to PyQt6 if PySide6 is
|
|
not installed. PyQt6 is the default available package on Debian/Ubuntu
|
|
without requiring pip; PySide6 ships with broader signal/slot semantics
|
|
but the API surface used here is portable between the two.
|
|
"""
|
|
import sys, os, re, subprocess, shutil
|
|
from pathlib import Path
|
|
|
|
# Try PySide6, fall back to PyQt6. The two have nearly-identical APIs;
|
|
# the only adjustment needed is Signal/pyqtSignal naming, handled below.
|
|
try:
|
|
from PySide6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QPushButton, QLineEdit, QComboBox, QFileDialog,
|
|
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
|
|
QSpinBox, QMessageBox, QStatusBar, QScrollArea
|
|
)
|
|
from PySide6.QtCore import Qt, Signal, QObject, QThread, QTimer, QEvent
|
|
from PySide6.QtGui import QPalette, QColor, QIcon, QPixmap
|
|
QT_BINDING = "PySide6"
|
|
except ImportError:
|
|
try:
|
|
from PyQt6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QLabel, QPushButton, QLineEdit, QComboBox, QFileDialog,
|
|
QTextEdit, QProgressBar, QTabWidget, QFrame, QCheckBox,
|
|
QSpinBox, QMessageBox, QStatusBar, QScrollArea
|
|
)
|
|
from PyQt6.QtCore import Qt, pyqtSignal as Signal, QObject, QThread, QTimer, QEvent
|
|
from PyQt6.QtGui import QPalette, QColor, QIcon, QPixmap
|
|
QT_BINDING = "PyQt6"
|
|
except ImportError:
|
|
if sys.stderr is not None: # None under PyInstaller --windowed
|
|
sys.stderr.write(
|
|
"ERROR: vaptvupt-gui requires PySide6 or PyQt6. Install one of:\n"
|
|
" Debian/Ubuntu: sudo apt install python3-pyqt6\n"
|
|
" Fedora/RHEL: sudo dnf install python3-pyqt6\n"
|
|
" pip (any OS): pip install PySide6\n"
|
|
)
|
|
sys.exit(1)
|
|
|
|
# ── Find vaptvupt binary ──
|
|
#
|
|
# v3.0.0 rename: the binary is now `vaptvupt`; older installations
|
|
# (1.x/2.x) ship `zupt`. We try the new name first, fall back to the
|
|
# old name, and on every candidate verify it's actually executable
|
|
# (not just present). After picking a candidate, we run a quick
|
|
# `version` liveness check — this catches the case where the binary
|
|
# exists but can't load its shared library (the original bug report:
|
|
# "GUI doesn't find zupt; copying to /usr/local/bin fixes it").
|
|
#
|
|
# Diagnostic output goes to stderr so users can `vaptvupt-gui 2>log`
|
|
# to see exactly which path was tried and why each failed.
|
|
|
|
_DISCOVERY_LOG = []
|
|
|
|
def _discovery_log(msg):
|
|
_DISCOVERY_LOG.append(msg)
|
|
# Echo to stderr if VAPTVUPT_DEBUG or ZUPT_DEBUG is set
|
|
if ((os.environ.get("VAPTVUPT_DEBUG") or os.environ.get("ZUPT_DEBUG"))
|
|
and sys.stderr is not None): # None under PyInstaller --windowed
|
|
sys.stderr.write(f" [discovery] {msg}\n")
|
|
|
|
def _is_runnable(path):
|
|
"""A path is runnable if it's a file, executable, and exits 0 on `version`."""
|
|
p = str(path)
|
|
if not os.path.isfile(p):
|
|
return False, "not a regular file"
|
|
if not os.access(p, os.X_OK):
|
|
return False, "not executable (chmod +x needed?)"
|
|
# Liveness check — catches missing shared libraries, broken rpath,
|
|
# ABI mismatch, etc. 3-second cap so we never hang the GUI startup.
|
|
try:
|
|
r = subprocess.run([p, "version"], capture_output=True, stdin=subprocess.DEVNULL, timeout=3)
|
|
if r.returncode != 0:
|
|
err = r.stderr.decode("utf-8", errors="replace").strip()
|
|
return False, f"exit {r.returncode}: {err.splitlines()[0] if err else 'no stderr'}"
|
|
return True, "OK"
|
|
except FileNotFoundError:
|
|
return False, "FileNotFoundError on exec"
|
|
except subprocess.TimeoutExpired:
|
|
return False, "timeout (3s) on `version` — binary hung"
|
|
except OSError as e:
|
|
return False, f"OSError: {e}"
|
|
|
|
def _find_vaptvupt():
|
|
# 1. Explicit env override
|
|
for env in ("VAPTVUPT_BIN", "ZUPT_BIN"):
|
|
p = os.environ.get(env)
|
|
if p:
|
|
ok, reason = _is_runnable(p)
|
|
_discovery_log(f"env {env}={p}: {reason}")
|
|
if ok:
|
|
return p
|
|
|
|
# 2. Local project tree (running from a source checkout)
|
|
# Try BOTH names (vaptvupt is v3.0.0+, zupt is legacy).
|
|
here = Path(getattr(sys, "_MEIPASS", Path(__file__).parent))
|
|
for parent in (here.parent.parent, here.parent, here):
|
|
for name in ("vaptvupt", "zupt", "vaptvupt.exe", "zupt.exe"):
|
|
c = parent / name
|
|
ok, reason = _is_runnable(c)
|
|
_discovery_log(f"local {c}: {reason}")
|
|
if ok:
|
|
return str(c.resolve())
|
|
|
|
# 3. System PATH — try new name first, then legacy
|
|
for name in ("vaptvupt", "zupt"):
|
|
found = shutil.which(name)
|
|
if found:
|
|
ok, reason = _is_runnable(found)
|
|
_discovery_log(f"PATH which({name})={found}: {reason}")
|
|
if ok:
|
|
return found
|
|
else:
|
|
_discovery_log(f"PATH which({name}): not found")
|
|
|
|
# 4. Hard-coded common install paths — catches the "GUI launched
|
|
# from a desktop session with a minimal PATH that omits /usr/bin"
|
|
# scenario reported against v2.4.8.
|
|
common = [
|
|
# New name (v3.0.0+)
|
|
"/usr/local/bin/vaptvupt", "/usr/bin/vaptvupt",
|
|
"/opt/vaptvupt/bin/vaptvupt", "/opt/homebrew/bin/vaptvupt",
|
|
# Legacy name (1.x/2.x)
|
|
"/usr/local/bin/zupt", "/usr/bin/zupt",
|
|
"/opt/zupt/bin/zupt", "/opt/homebrew/bin/zupt",
|
|
# Termux (Android) install path
|
|
"/data/data/com.termux/files/usr/bin/vaptvupt",
|
|
"/data/data/com.termux/files/usr/bin/zupt",
|
|
# Flatpak sandbox runtime path
|
|
"/app/bin/vaptvupt", "/app/bin/zupt",
|
|
]
|
|
for path in common:
|
|
ok, reason = _is_runnable(path)
|
|
_discovery_log(f"common {path}: {reason}")
|
|
if ok:
|
|
return path
|
|
|
|
# 5. Last resort — return "vaptvupt" and let exec fail loudly later.
|
|
# A caller-visible error is better than silently returning a path
|
|
# that doesn't work.
|
|
_discovery_log("FAILED: no runnable vaptvupt/zupt binary found")
|
|
return "vaptvupt"
|
|
|
|
# Backward-compat: code elsewhere in this file still uses `ZUPT`.
|
|
VAPTVUPT = _find_vaptvupt()
|
|
ZUPT = VAPTVUPT # legacy alias used throughout the rest of zupt_gui.py
|
|
|
|
# ── Query version ONCE at import (cached) ──
|
|
#
|
|
# The CLI's `version` first line is the brand banner. Examples:
|
|
# v2.4.x: "zupt 2.4.8"
|
|
# v3.0.x: "vaptvupt 3.0.0 (formerly zupt; renamed in v3.0.0 — INPI Brasil trademark)"
|
|
#
|
|
# We extract three things from that line:
|
|
# - VER_SHORT: the full first line (used as a fallback display)
|
|
# - VER_NUMBER: just the version number "3.0.0" or "2.4.8" (for hero text)
|
|
# - VER_FULL: the entire stdout (used in the about panel)
|
|
#
|
|
# Earlier code did `ZUPT_VER_SHORT.replace("zupt ", "")` to peel the
|
|
# product name. That breaks on v3.0.x because the same string "zupt "
|
|
# also appears inside the parenthetical "formerly zupt; renamed".
|
|
# We now use a strict regex anchored at the start of the line.
|
|
|
|
_VERSION_RE = re.compile(r'^(?:vaptvupt|zupt)\s+(\d+\.\d+\.\d+(?:[._A-Za-z0-9-]*)?)')
|
|
|
|
def _get_version():
|
|
short = "vaptvupt (not found)"
|
|
number = "?"
|
|
full = ""
|
|
try:
|
|
r = subprocess.run([VAPTVUPT, "version"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
|
if r.returncode == 0:
|
|
full = r.stdout.strip()
|
|
lines = full.split("\n")
|
|
short = lines[0]
|
|
m = _VERSION_RE.match(short)
|
|
if m:
|
|
number = m.group(1)
|
|
except Exception:
|
|
pass
|
|
return short, number, full
|
|
|
|
ZUPT_VER_SHORT, ZUPT_VER_NUMBER, ZUPT_VER_FULL = _get_version()
|
|
|
|
# ── Detect build capabilities from `version` (and `help` as fallback) ──
|
|
#
|
|
# The default build is SOURCE-ONLY: the libzuptsdk-backed modes (Argon2id
|
|
# KDF, --pq-sdk, --pq-box) are absent and fail with exit 1. Offering them in
|
|
# the UI is the #1 reason "functions don't work". We detect what THIS binary
|
|
# actually supports and build the encryption UI around it:
|
|
# - SDK_AVAILABLE : --pq-sdk / --pq-box / Argon2id compiled in (WITH_SDK=1)
|
|
# - PQONLY_AVAILABLE: native --pq-only (full post-quantum, v4.2.0+)
|
|
# - DEFAULT_KDF : the password KDF this build actually uses
|
|
# The `version` banner carries a machine-readable "Build:" line (v4.2.1+);
|
|
# for older binaries we fall back to `help` text and default SDK off (safe:
|
|
# the native --pq / --pq-only / password modes work on every build).
|
|
def _get_caps():
|
|
sdk = False
|
|
pqonly = False
|
|
default_kdf = "PBKDF2-SHA256"
|
|
blob = ZUPT_VER_FULL or ""
|
|
for line in blob.splitlines():
|
|
low = line.lower()
|
|
if low.startswith("build:"):
|
|
sdk = ("full" in low) and ("libzuptsdk" in low)
|
|
elif low.startswith("kdf:"):
|
|
default_kdf = "Argon2id" if "argon2id (default)" in low else "PBKDF2-SHA256"
|
|
if "--pq-only" in line:
|
|
pqonly = True
|
|
if not pqonly:
|
|
try:
|
|
h = subprocess.run([VAPTVUPT, "help"], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=5)
|
|
txt = (h.stdout or "") + (h.stderr or "")
|
|
if "--pq-only" in txt:
|
|
pqonly = True
|
|
except Exception:
|
|
pass
|
|
return sdk, pqonly, default_kdf
|
|
|
|
SDK_AVAILABLE, PQONLY_AVAILABLE, DEFAULT_KDF = _get_caps()
|
|
|
|
# Post-quantum recipient modes offered in the UI, keyed to CLI flags.
|
|
# token -> (label, keygen-flag-list, compress/extract-flag)
|
|
# keygen-flags are extra flags added to `keygen` (private) and `keygen --pub`.
|
|
def pq_mode_options(include_auto=False):
|
|
"""Return [(label, token)] for a PQ-mode dropdown given this build."""
|
|
opts = []
|
|
if include_auto:
|
|
opts.append(("Auto-detect from archive", "auto"))
|
|
opts.append(("Hybrid — ML-KEM-768 + X25519 (recommended)", "pq"))
|
|
if PQONLY_AVAILABLE:
|
|
opts.append(("Full PQ — ML-KEM-768 only", "pqonly"))
|
|
if SDK_AVAILABLE:
|
|
opts.append(("SDK v2 — HKDF + commitment + HPKE", "sdk"))
|
|
return opts
|
|
|
|
# token -> (extra keygen flags, encrypt/decrypt flag)
|
|
_PQ_FLAG = {
|
|
"pq": ([], "--pq"),
|
|
"pqonly": (["--pq-only"], "--pq-only"),
|
|
"sdk": (["--sdk"], "--pq-sdk"),
|
|
}
|
|
|
|
def _detect_archive_pq(archive):
|
|
"""Inspect an archive's `info` and return the matching PQ token, or None."""
|
|
try:
|
|
r = subprocess.run([VAPTVUPT, "info", archive], capture_output=True, stdin=subprocess.DEVNULL, text=True, timeout=15)
|
|
txt = (r.stdout or "") + (r.stderr or "")
|
|
except Exception:
|
|
return None
|
|
low = txt.lower()
|
|
if "ml-kem-768 only" in low or "no classical" in low:
|
|
return "pqonly"
|
|
if "sdk v2" in low or "hpke" in low:
|
|
return "sdk"
|
|
if "ml-kem-768" in low or "hybrid" in low or "x25519" in low:
|
|
return "pq"
|
|
return None
|
|
|
|
# ── Find icon file ──
|
|
def _find_icon():
|
|
here = Path(getattr(sys, '_MEIPASS', Path(__file__).parent))
|
|
for p in [here/"assets/zupt.ico", here/"assets/zupt-icon.png", here/"assets/zupt.png",
|
|
here.parent/"assets/zupt.ico", here.parent/"assets/zupt-icon.png",
|
|
Path("/usr/share/icons/hicolor/256x256/apps/zupt-gui.png"),
|
|
Path("/usr/share/zupt-gui/assets/zupt-icon.png")]:
|
|
if p.is_file(): return str(p)
|
|
return None
|
|
|
|
ICON_PATH = _find_icon()
|
|
|
|
STYLE = """
|
|
* { font-family: "Segoe UI", "Cantarell", "Noto Sans", sans-serif; }
|
|
QMainWindow { background: #0a0a0a; }
|
|
QTabWidget::pane { background: #0a0a0a; border: none; border-top: 1px solid #1a2a30; }
|
|
QTabBar { background: #050a0e; }
|
|
QTabBar::tab {
|
|
background: #050a0e; color: #5a7a88; padding: 11px 22px;
|
|
border: none; border-bottom: 2px solid transparent;
|
|
font-weight: 600; font-size: 12px;
|
|
}
|
|
QTabBar::tab:hover { color: #a0c8d8; background: #0a1018; }
|
|
QTabBar::tab:selected { color: #00dde0; border-bottom-color: #00dde0; background: #0c1218; }
|
|
QLabel { color: #90acb8; }
|
|
QLineEdit, QComboBox, QSpinBox {
|
|
background: #0e1820; color: #c0dce8; border: 1px solid #1a2a30;
|
|
border-radius: 6px; padding: 9px 12px;
|
|
font-family: "JetBrains Mono", "Cascadia Code", "SF Mono", monospace; font-size: 13px;
|
|
selection-background-color: #004858;
|
|
}
|
|
QLineEdit:focus, QComboBox:focus, QSpinBox:focus { border-color: #006878; }
|
|
QComboBox::drop-down { border: none; padding-right: 8px; }
|
|
QComboBox QAbstractItemView { background: #0e1820; color: #c0dce8; border: 1px solid #1a2a30; selection-background-color: #004858; }
|
|
QPushButton {
|
|
background: #0a2028; color: #00dde0; border: 1px solid #1a3a40;
|
|
border-radius: 6px; padding: 10px 24px; font-weight: 600; font-size: 12px;
|
|
}
|
|
QPushButton:hover { background: #0e2830; border-color: #00dde0; }
|
|
QPushButton:pressed { background: #062020; }
|
|
QPushButton:disabled { color: #304048; border-color: #151e22; }
|
|
QPushButton#green { color: #00c868; border-color: #0a3020; background: #061a10; }
|
|
QPushButton#green:hover { border-color: #00c868; background: #0a2818; }
|
|
QPushButton#amber { color: #d0a020; border-color: #2a2010; background: #181200; }
|
|
QPushButton#amber:hover { border-color: #d0a020; background: #201800; }
|
|
QPushButton#small { padding: 8px 14px; font-size: 11px; }
|
|
QTextEdit {
|
|
background: #060c10; color: #608898; border: 1px solid #1a2a30;
|
|
border-radius: 6px; padding: 10px;
|
|
font-family: "JetBrains Mono", "Cascadia Code", "SF Mono", monospace; font-size: 11px;
|
|
}
|
|
QProgressBar { background: #0e1820; border: none; border-radius: 3px; max-height: 6px; }
|
|
QProgressBar::chunk { background: qlineargradient(x1:0,y1:0,x2:1,y2:0,stop:0 #00dde0,stop:1 #008090); border-radius: 3px; }
|
|
QCheckBox { color: #6a8898; spacing: 6px; font-size: 13px; }
|
|
QCheckBox::indicator { width: 16px; height: 16px; border-radius: 3px; border: 1px solid #1a3a40; background: #0e1820; }
|
|
QCheckBox::indicator:checked { background: #004858; border-color: #00dde0; }
|
|
QScrollArea { border: none; background: transparent; }
|
|
QStatusBar { background: #050a0e; color: #3a5868; font-size: 11px; border-top: 1px solid #1a2a30; }
|
|
QFrame#sep { background: #1a2a30; max-height: 1px; }
|
|
"""
|
|
|
|
def run_zupt(args, timeout=30):
|
|
try:
|
|
r = subprocess.run([ZUPT]+list(args), capture_output=True, text=True,
|
|
stdin=subprocess.DEVNULL, timeout=timeout)
|
|
return r.returncode, r.stdout, r.stderr
|
|
except FileNotFoundError: return -1, "", f"vaptvupt not found: {VAPTVUPT}\n\nDiscovery log:\n" + "\n".join(_DISCOVERY_LOG[-10:])
|
|
except subprocess.TimeoutExpired: return -1, "", "Timed out"
|
|
|
|
class Worker(QObject):
|
|
done = Signal(int, str, str)
|
|
log = Signal(str)
|
|
pct = Signal(int)
|
|
|
|
# The CLI paints live progress as "\r <path> [##### ] 42%" frames —
|
|
# carriage returns only, no newline until 100%. A line-based reader yields
|
|
# NOTHING for the entire job, so the GUI looked frozen on any file larger
|
|
# than one block ("app is stuck"). Parse the \r frames into a percentage.
|
|
_PCT_RE = re.compile(r"(\d{1,3})%\s*$")
|
|
|
|
def __init__(self, args):
|
|
super().__init__(); self.args = args; self.proc = None; self._cancelled = False
|
|
def run(self):
|
|
self.log.emit(f"$ {Path(VAPTVUPT).name} {' '.join(self.args)}")
|
|
try:
|
|
# stdin=DEVNULL: the CLI prompts on a terminal for some inputs
|
|
# (e.g. bare -p); a child that reads stdin inherited from the GUI's
|
|
# terminal would block forever. /dev/null makes prompts fail fast.
|
|
# stderr is merged into stdout so ONE stream carries everything
|
|
# (the CLI's human output is on stderr; stdout is empty) — no
|
|
# second pipe that could fill while we drain the first.
|
|
self.proc = proc = subprocess.Popen(
|
|
[ZUPT]+self.args, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL)
|
|
if self._cancelled: # cancel() ran before Popen finished (see below)
|
|
proc.kill()
|
|
lines = []
|
|
buf = ""
|
|
last_pct = -1
|
|
while True:
|
|
# read1: return whatever bytes are available (>=1) instead of
|
|
# blocking for a full buffer — required for live \r progress.
|
|
chunk = proc.stdout.read1(65536)
|
|
if not chunk:
|
|
break
|
|
buf += chunk.decode("utf-8", errors="replace")
|
|
# Split on BOTH \n (real lines) and \r (progress frames);
|
|
# keep the trailing partial segment in the buffer.
|
|
segs = re.split(r"(\r\n|\n|\r)", buf)
|
|
buf = segs[-1]
|
|
for i in range(0, len(segs) - 1, 2):
|
|
seg, sep = segs[i], segs[i + 1]
|
|
if sep == "\r" or (seg and self._PCT_RE.search(seg) and "[" in seg):
|
|
m = self._PCT_RE.search(seg)
|
|
if m:
|
|
p = min(100, int(m.group(1)))
|
|
if p != last_pct:
|
|
last_pct = p; self.pct.emit(p)
|
|
continue # progress frames stay out of the log
|
|
if seg.strip():
|
|
lines.append(seg); self.log.emit(seg)
|
|
proc.wait(timeout=7200)
|
|
if buf.strip() and not self._PCT_RE.search(buf):
|
|
lines.append(buf); self.log.emit(buf)
|
|
self.done.emit(proc.returncode, "", "\n".join(lines))
|
|
except FileNotFoundError: self.done.emit(-1, "", f"vaptvupt not found: {VAPTVUPT}")
|
|
except subprocess.TimeoutExpired: proc.kill(); self.done.emit(-1, "", "Timed out")
|
|
except Exception as exc:
|
|
# Any escape from this slot would strand the job forever (done never
|
|
# fires -> button stays disabled; fatal under PyQt6). Always report.
|
|
self.done.emit(-1, "", f"{type(exc).__name__}: {exc}")
|
|
def cancel(self):
|
|
"""Kill the child CLI process (called from the GUI thread on window
|
|
close). run() then sees EOF/exit and finishes the thread normally.
|
|
The flag closes the startup race: if cancel() runs before run() has
|
|
assigned self.proc, run() kills the child right after spawning it."""
|
|
self._cancelled = True
|
|
p = self.proc
|
|
if p is not None and p.poll() is None:
|
|
try: p.kill()
|
|
except OSError: pass
|
|
|
|
# ── Widgets ──
|
|
|
|
def H(text):
|
|
lbl = QLabel(text.upper())
|
|
lbl.setStyleSheet("font-size:10px;font-weight:700;color:#3a5868;letter-spacing:1.5px;")
|
|
return lbl
|
|
|
|
def Sep():
|
|
f = QFrame(); f.setObjectName("sep"); f.setFixedHeight(1); return f
|
|
|
|
def Log(h=150):
|
|
t = QTextEdit(); t.setReadOnly(True); t.setMaximumHeight(h); return t
|
|
|
|
def PwField(ph="Optional"):
|
|
pw = QLineEdit(); pw.setEchoMode(QLineEdit.EchoMode.Password); pw.setPlaceholderText(ph); return pw
|
|
|
|
class PathField(QWidget):
|
|
def __init__(self, ph="", mode="open", filters="All Files (*)"):
|
|
super().__init__()
|
|
self.mode, self.filters = mode, filters
|
|
self.setAcceptDrops(True)
|
|
lay = QHBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(6)
|
|
self.edit = QLineEdit(); self.edit.setPlaceholderText(ph)
|
|
lay.addWidget(self.edit, 1)
|
|
btn = QPushButton("Browse"); btn.setObjectName("small"); btn.setFixedWidth(70)
|
|
btn.clicked.connect(self._pick); lay.addWidget(btn)
|
|
|
|
def _pick(self):
|
|
if self.mode == "save": p, _ = QFileDialog.getSaveFileName(self, "Save", "", self.filters)
|
|
elif self.mode == "dir": p = QFileDialog.getExistingDirectory(self, "Directory")
|
|
elif self.mode == "multi":
|
|
ps, _ = QFileDialog.getOpenFileNames(self, "Select", "", self.filters)
|
|
p = "|".join(ps) if ps else ""
|
|
else: p, _ = QFileDialog.getOpenFileName(self, "Open", "", self.filters)
|
|
if p: self.edit.setText(p)
|
|
|
|
def path(self): return self.edit.text().strip()
|
|
def paths(self):
|
|
t = self.path(); return t.split("|") if "|" in t else [t] if t else []
|
|
def dragEnterEvent(self, e):
|
|
if e.mimeData().hasUrls(): e.acceptProposedAction()
|
|
def dropEvent(self, e):
|
|
ps = [u.toLocalFile() for u in e.mimeData().urls() if u.toLocalFile()]
|
|
if ps: self.edit.setText("|".join(ps) if self.mode == "multi" else ps[0])
|
|
|
|
def scrollable(w):
|
|
sa = QScrollArea(); sa.setWidgetResizable(True); sa.setWidget(w); sa.setFrameShape(QFrame.Shape.NoFrame); return sa
|
|
|
|
class _Job(QObject):
|
|
"""Controller for one async CLI run.
|
|
|
|
CRITICAL threading contract: this object is parented to a GUI-thread widget,
|
|
so it LIVES in the GUI thread, and every slot below (on_log/on_pct/on_done/
|
|
on_finished) is a bound method of a GUI-thread QObject. Qt therefore auto-
|
|
marshals the worker's signals to the GUI thread (QueuedConnection).
|
|
|
|
The previous design connected plain Python CLOSURES (finish/on_pct/release)
|
|
to signals emitted from the worker thread. PySide6 runs a plain-closure slot
|
|
in the EMITTING thread regardless of the requested connection type — even an
|
|
explicit Qt.QueuedConnection — because a bare functor has no receiver QObject
|
|
to give it thread affinity (verified empirically). Those closures then
|
|
touched QProgressBar / QPushButton / QTextEdit internals from the worker
|
|
thread: cross-thread QWidget access, which is undefined behaviour and crashed
|
|
the app under real X11/Wayland rendering ("the app closes when I compress").
|
|
It only survived offscreen tests, which tolerate the race. Bound methods of a
|
|
GUI-thread QObject are the fix."""
|
|
def __init__(self, parent, cmd, btn, log, progress):
|
|
super().__init__(parent)
|
|
self._parent = parent
|
|
self.btn, self.log, self.progress = btn, log, progress
|
|
self.thread = QThread(self) # QThread object lives in GUI thread
|
|
self.worker = Worker(cmd) # no parent — it moves to self.thread
|
|
self.worker.moveToThread(self.thread)
|
|
self.worker.log.connect(self.on_log)
|
|
self.worker.pct.connect(self.on_pct)
|
|
self.worker.done.connect(self.on_done)
|
|
self.thread.finished.connect(self.on_finished)
|
|
self.thread.started.connect(self.worker.run)
|
|
|
|
def start(self):
|
|
self.thread.start()
|
|
|
|
def on_log(self, line):
|
|
self.log.append(line)
|
|
|
|
def on_pct(self, p):
|
|
if self.progress is not None:
|
|
if self.progress.maximum() != 100:
|
|
self.progress.setRange(0, 100)
|
|
self.progress.setValue(p)
|
|
|
|
def on_done(self, code, out, err):
|
|
self.btn.setEnabled(True)
|
|
if self.progress is not None:
|
|
self.progress.hide()
|
|
self.log.append("\nDone." if code == 0 else f"\nFailed (exit {code}).")
|
|
self.thread.quit()
|
|
|
|
def on_finished(self):
|
|
# Runs on the GUI thread AFTER the QThread has emitted finished(); the
|
|
# wait() joins the last native teardown so dropping the last Python ref
|
|
# can't collect a still-running QThread (that aborts with "QThread:
|
|
# Destroyed while thread is still running").
|
|
self.thread.wait()
|
|
try:
|
|
self._parent._jobs.remove(self)
|
|
except (AttributeError, ValueError):
|
|
pass
|
|
|
|
def cancel_and_join(self, ms=3000):
|
|
"""GUI thread: kill the child CLI and join the worker thread."""
|
|
self.worker.cancel()
|
|
self.thread.quit()
|
|
return self.thread.wait(ms)
|
|
|
|
|
|
def run_async(parent, cmd, btn, log, progress=None, info=None):
|
|
log.clear()
|
|
if info: # e.g. an auto-detect note; appended AFTER the clear so it survives
|
|
log.append(info)
|
|
btn.setEnabled(False)
|
|
if progress:
|
|
progress.setRange(0, 0) # indeterminate until the CLI reports a %
|
|
progress.setValue(0)
|
|
progress.show()
|
|
# Keep a LIST of live jobs on the parent. Tabs with more than one action
|
|
# button (Disk: backup + restore) previously shared a single slot, so
|
|
# starting a second op dropped the only Python reference to the first
|
|
# still-running QThread and Python GC'd it mid-run. The list holds every
|
|
# in-flight job (and keeps the QThread alive).
|
|
if not hasattr(parent, "_jobs"):
|
|
parent._jobs = []
|
|
job = _Job(parent, cmd, btn, log, progress)
|
|
parent._jobs.append(job)
|
|
job.start()
|
|
|
|
# ── Tabs ──
|
|
|
|
class KeysTab(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
|
|
|
|
v.addWidget(QLabel("Generate or export post-quantum keys (ML-KEM-768)."))
|
|
v.addWidget(Sep())
|
|
|
|
# Key type governs both generate and export so the two stay consistent.
|
|
v.addWidget(H("Key type"))
|
|
self.mode = QComboBox()
|
|
self._modes = pq_mode_options()
|
|
for label, _tok in self._modes:
|
|
self.mode.addItem(label)
|
|
self.mode.setToolTip("Hybrid (--pq) is recommended. Full PQ (--pq-only) drops the\n"
|
|
"classical X25519 layer for PQ-only compliance postures. Both use\n"
|
|
"in-tree crypto and work on every build.")
|
|
v.addWidget(self.mode)
|
|
v.addWidget(Sep())
|
|
|
|
# Section 1: Generate new keypair
|
|
v.addWidget(H("Generate new keypair"))
|
|
v.addWidget(QLabel("Writes a private key and its matching public key."))
|
|
|
|
v.addWidget(H("Private key output"))
|
|
self.gen_priv = PathField("e.g. ~/vaptvupt_private.key", "save", "Key (*.key);;All (*)")
|
|
v.addWidget(self.gen_priv)
|
|
v.addWidget(H("Public key output"))
|
|
self.gen_pub = PathField("e.g. ~/vaptvupt_public.key", "save", "Key (*.key);;All (*)")
|
|
v.addWidget(self.gen_pub)
|
|
|
|
self.gen_btn = QPushButton("Generate Keypair")
|
|
self.gen_btn.clicked.connect(self._generate)
|
|
v.addWidget(self.gen_btn)
|
|
self.gen_log = Log(120); v.addWidget(self.gen_log)
|
|
|
|
v.addWidget(Sep())
|
|
|
|
# Section 2: Export public key from existing private key
|
|
v.addWidget(H("Export public key from private key"))
|
|
v.addWidget(QLabel("Extract the public key from an existing private key file "
|
|
"(uses the key type selected above)."))
|
|
|
|
v.addWidget(H("Existing private key"))
|
|
self.exp_priv = PathField("Select private key", "open", "Key (*.key);;All (*)")
|
|
v.addWidget(self.exp_priv)
|
|
|
|
v.addWidget(H("Public key output"))
|
|
self.exp_pub = PathField("e.g. ~/vaptvupt_public.key", "save", "Key (*.key);;All (*)")
|
|
v.addWidget(self.exp_pub)
|
|
|
|
self.exp_btn = QPushButton("Export Public Key")
|
|
self.exp_btn.setObjectName("green")
|
|
self.exp_btn.clicked.connect(self._export)
|
|
v.addWidget(self.exp_btn)
|
|
self.exp_log = Log(100); v.addWidget(self.exp_log)
|
|
|
|
v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
|
|
def _token(self):
|
|
return self._modes[self.mode.currentIndex()][1]
|
|
|
|
def _default_pub(self, priv):
|
|
return (priv.rsplit(".", 1)[0] + "_public.key") if "." in priv else priv + ".pub"
|
|
|
|
def _generate(self):
|
|
p = self.gen_priv.path() or str(Path.home() / "vaptvupt_private.key")
|
|
self.gen_priv.edit.setText(p)
|
|
pub = self.gen_pub.path() or self._default_pub(p)
|
|
self.gen_pub.edit.setText(pub)
|
|
tok = self._token()
|
|
kflags, _ = _PQ_FLAG[tok]
|
|
self.gen_log.clear(); self.gen_btn.setEnabled(False)
|
|
if tok == "sdk":
|
|
# SDK keygen writes the private key and <priv>.pub in one step.
|
|
code, _, err = run_zupt(["keygen", "--sdk", "-o", p])
|
|
self.gen_log.append(err.strip())
|
|
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {p}.pub" if code == 0 else "\nFailed.")
|
|
else:
|
|
code, _, err = run_zupt(["keygen"] + kflags + ["-o", p])
|
|
self.gen_log.append(err.strip())
|
|
if code == 0:
|
|
c2, _, e2 = run_zupt(["keygen", "--pub"] + kflags + ["-o", pub, "-k", p])
|
|
self.gen_log.append(e2.strip())
|
|
self.gen_log.append(f"\nPrivate key: {p}\nPublic key: {pub}" if c2 == 0 else "\nFailed to export public key.")
|
|
else:
|
|
self.gen_log.append("\nFailed.")
|
|
self.gen_btn.setEnabled(True)
|
|
|
|
def _export(self):
|
|
priv = self.exp_priv.path()
|
|
pub = self.exp_pub.path()
|
|
if not priv: QMessageBox.warning(self, "VaptVupt", "Select the private key file."); return
|
|
if not pub:
|
|
pub = self._default_pub(priv); self.exp_pub.edit.setText(pub)
|
|
tok = self._token()
|
|
kflags, _ = _PQ_FLAG[tok]
|
|
self.exp_log.clear(); self.exp_btn.setEnabled(False)
|
|
code, _, err = run_zupt(["keygen", "--pub"] + kflags + ["-o", pub, "-k", priv])
|
|
self.exp_log.append(err.strip())
|
|
self.exp_log.append(f"\nPublic key: {pub}" if code == 0 else "\nFailed.")
|
|
self.exp_btn.setEnabled(True)
|
|
|
|
|
|
class CompressTab(QWidget):
|
|
def __init__(self, initial=None):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
|
|
v.addWidget(QLabel("Compress files into an encrypted .zupt archive."))
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Source files / directory"))
|
|
self.src = PathField("Drop files here or browse", "multi"); v.addWidget(self.src)
|
|
v.addWidget(H("Output archive"))
|
|
self.dst = PathField("e.g. backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.dst)
|
|
row = QHBoxLayout(); row.setSpacing(16)
|
|
for label, widget in [("Codec", self._mk_codec()), ("Level", self._mk_level())]:
|
|
c = QVBoxLayout(); c.addWidget(H(label)); c.addWidget(widget); row.addLayout(c)
|
|
c = QVBoxLayout(); c.addWidget(H("Options"))
|
|
self.dedup = QCheckBox("Dedup"); self.solid = QCheckBox("Solid")
|
|
oh = QHBoxLayout(); oh.addWidget(self.dedup); oh.addWidget(self.solid); oh.addStretch()
|
|
c.addLayout(oh); row.addLayout(c)
|
|
v.addLayout(row); v.addWidget(Sep())
|
|
enc = QHBoxLayout(); enc.setSpacing(16)
|
|
pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField("AES-256"); pw.addWidget(self.pw); enc.addLayout(pw)
|
|
pq = QVBoxLayout(); pq.addWidget(H("PQ public key")); self.pq = PathField("Optional .key", filters="Key (*.key *.pub);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq)
|
|
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
|
|
self.pqmode = QComboBox()
|
|
self._pqmodes = pq_mode_options()
|
|
for label, _tok in self._pqmodes:
|
|
self.pqmode.addItem(label)
|
|
self.pqmode.setToolTip("Applies when a PQ public key is set. Must match the key type\n"
|
|
"you generated. Hybrid (--pq) is recommended.")
|
|
mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_box)
|
|
v.addLayout(enc)
|
|
self.btn = QPushButton("Compress"); self.btn.clicked.connect(self._run); v.addWidget(self.btn)
|
|
self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress)
|
|
self.log = Log(140); v.addWidget(self.log); v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
if initial: self.src.edit.setText("|".join(initial))
|
|
|
|
def _mk_codec(self):
|
|
self.codec = QComboBox(); self.codec.addItems(["AUTO","VaptVupt","LZHP","Store"]); return self.codec
|
|
def _mk_level(self):
|
|
self.level = QSpinBox(); self.level.setRange(1,9); self.level.setValue(7); return self.level
|
|
|
|
def _run(self):
|
|
srcs = self.src.paths()
|
|
if not srcs or not srcs[0]: QMessageBox.warning(self, "VaptVupt", "Select files."); return
|
|
dst = self.dst.path() or srcs[0] + ".zupt"; self.dst.edit.setText(dst)
|
|
cmd = ["compress", "-l", str(self.level.value())]
|
|
cm = {"AUTO": None, "VaptVupt": "--vv", "LZHP": "--lzhp", "Store": "-s"}
|
|
if cm.get(self.codec.currentText()): cmd.append(cm[self.codec.currentText()])
|
|
if self.dedup.isChecked(): cmd.append("--dedup")
|
|
if self.solid.isChecked(): cmd.append("--solid")
|
|
if self.pw.text(): cmd += ["-p", self.pw.text()]
|
|
if self.pq.path():
|
|
tok = self._pqmodes[self.pqmode.currentIndex()][1]
|
|
_, flag = _PQ_FLAG[tok]
|
|
cmd += [flag, self.pq.path()]
|
|
cmd.append(dst); cmd.extend(srcs)
|
|
run_async(self, cmd, self.btn, self.log, self.progress)
|
|
|
|
|
|
class ExtractTab(QWidget):
|
|
def __init__(self, initial=None):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
|
|
v.addWidget(QLabel("Extract and decrypt a .zupt archive."))
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Archive")); self.arc = PathField("Drop .zupt here", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.arc)
|
|
v.addWidget(H("Output directory")); self.out = PathField("Same as archive", "dir"); v.addWidget(self.out)
|
|
enc = QHBoxLayout(); enc.setSpacing(16)
|
|
pw = QVBoxLayout(); pw.addWidget(H("Password")); self.pw = PwField(); pw.addWidget(self.pw); enc.addLayout(pw)
|
|
pq = QVBoxLayout(); pq.addWidget(H("PQ private key")); self.pq = PathField("Optional .key", filters="Key (*.key);;All (*)"); pq.addWidget(self.pq); enc.addLayout(pq)
|
|
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
|
|
self.pqmode = QComboBox()
|
|
self._pqmodes = pq_mode_options(include_auto=True)
|
|
for label, _tok in self._pqmodes:
|
|
self.pqmode.addItem(label)
|
|
self.pqmode.setToolTip("Auto-detect reads the archive header (vaptvupt info) to pick the\n"
|
|
"right mode. Or choose it explicitly to match your private key.")
|
|
mode_box.addWidget(self.pqmode); mode_box.addStretch(); enc.addLayout(mode_box)
|
|
v.addLayout(enc)
|
|
self.btn = QPushButton("Extract"); self.btn.setObjectName("green"); self.btn.clicked.connect(self._run); v.addWidget(self.btn)
|
|
self.progress = QProgressBar(); self.progress.setRange(0,0); self.progress.hide(); v.addWidget(self.progress)
|
|
self.log = Log(140); v.addWidget(self.log); v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
if initial: self.arc.edit.setText(initial)
|
|
|
|
def _run(self):
|
|
arc = self.arc.path()
|
|
if not arc: QMessageBox.warning(self, "VaptVupt", "Select an archive."); return
|
|
cmd = ["extract"]
|
|
info = None
|
|
if self.out.path(): cmd += ["-o", self.out.path()]
|
|
if self.pw.text(): cmd += ["-p", self.pw.text()]
|
|
if self.pq.path():
|
|
tok = self._pqmodes[self.pqmode.currentIndex()][1]
|
|
if tok == "auto":
|
|
# The private-key format must match how the archive was encrypted;
|
|
# inspect the header (vaptvupt info) to choose the right flag.
|
|
tok = _detect_archive_pq(arc) or "pq"
|
|
info = f"[auto-detect] using {_PQ_FLAG[tok][1]}"
|
|
_, flag = _PQ_FLAG[tok]
|
|
cmd += [flag, self.pq.path()]
|
|
cmd.append(arc)
|
|
run_async(self, cmd, self.btn, self.log, self.progress, info=info)
|
|
|
|
|
|
class VerifyTab(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
|
|
v.addWidget(QLabel("Verify checksums or inspect archive metadata."))
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Verify integrity"))
|
|
self.varc = PathField("Archive to verify", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.varc)
|
|
enc = QHBoxLayout(); enc.setSpacing(16)
|
|
pw = QVBoxLayout(); pw.addWidget(H("Password (if encrypted)")); self.vpw = PwField("Leave empty if not encrypted"); pw.addWidget(self.vpw); enc.addLayout(pw)
|
|
pq = QVBoxLayout(); pq.addWidget(H("PQ private key")); self.vpq = PathField("For --pq / --pq-only archives", filters="Key (*.key);;All (*)"); pq.addWidget(self.vpq); enc.addLayout(pq)
|
|
mode_box = QVBoxLayout(); mode_box.addWidget(H("PQ mode"))
|
|
self.vpqmode = QComboBox()
|
|
self._vpqmodes = pq_mode_options(include_auto=True)
|
|
for label, _tok in self._vpqmodes:
|
|
self.vpqmode.addItem(label)
|
|
mode_box.addWidget(self.vpqmode); mode_box.addStretch(); enc.addLayout(mode_box)
|
|
v.addLayout(enc)
|
|
self.vbtn = QPushButton("Verify"); self.vbtn.setObjectName("amber"); self.vbtn.clicked.connect(self._verify); v.addWidget(self.vbtn)
|
|
self.vlog = Log(120); v.addWidget(self.vlog)
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Archive info (no password needed)"))
|
|
self.iarc = PathField("Archive to inspect", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.iarc)
|
|
self.ibtn = QPushButton("Show Info"); self.ibtn.clicked.connect(self._info); v.addWidget(self.ibtn)
|
|
self.ilog = Log(140); v.addWidget(self.ilog); v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
|
|
def _verify(self):
|
|
arc = self.varc.path()
|
|
if not arc: return
|
|
cmd = ["test"]
|
|
if self.vpw.text(): cmd += ["-p", self.vpw.text()]
|
|
self.vlog.clear()
|
|
if self.vpq.path():
|
|
tok = self._vpqmodes[self.vpqmode.currentIndex()][1]
|
|
if tok == "auto":
|
|
tok = _detect_archive_pq(arc) or "pq"
|
|
self.vlog.append(f"[auto-detect] using {_PQ_FLAG[tok][1]}")
|
|
_, flag = _PQ_FLAG[tok]
|
|
cmd += [flag, self.vpq.path()]
|
|
cmd.append(arc)
|
|
code, out, err = run_zupt(cmd, timeout=600)
|
|
self.vlog.append((err + "\n" + out).strip())
|
|
self.vlog.append("\nAll checksums passed." if code == 0 else "\nVerification failed.")
|
|
|
|
def _info(self):
|
|
arc = self.iarc.path()
|
|
if not arc: return
|
|
self.ilog.clear()
|
|
code, out, err = run_zupt(["info", arc])
|
|
self.ilog.append(out.strip() if out.strip() else err.strip())
|
|
|
|
|
|
class DiskTab(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(10)
|
|
v.addWidget(QLabel("Full-disk or partition backup and restore."))
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Backup — source device or image"))
|
|
self.bsrc = PathField("/dev/sdX or disk.img"); v.addWidget(self.bsrc)
|
|
v.addWidget(H("Backup — output archive"))
|
|
self.bout = PathField("backup.zupt", "save", "VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.bout)
|
|
bopt = QHBoxLayout(); bopt.setSpacing(16)
|
|
oc = QVBoxLayout(); oc.addWidget(H("Options")); self.bdedup = QCheckBox("Block deduplication"); oc.addWidget(self.bdedup); bopt.addLayout(oc)
|
|
pc = QVBoxLayout(); pc.addWidget(H("Password")); self.bpw = PwField("Optional — AES-256"); pc.addWidget(self.bpw); bopt.addLayout(pc)
|
|
v.addLayout(bopt)
|
|
self.bbtn = QPushButton("Start Backup"); self.bbtn.clicked.connect(self._backup); v.addWidget(self.bbtn)
|
|
self.blog = Log(100); v.addWidget(self.blog)
|
|
v.addWidget(Sep())
|
|
v.addWidget(H("Restore — archive"))
|
|
self.rarc = PathField("backup.zupt", filters="VaptVupt archive (*.zupt);;All (*)"); v.addWidget(self.rarc)
|
|
v.addWidget(H("Restore — target device or file"))
|
|
self.rtgt = PathField("/dev/sdX or output.img", "save"); v.addWidget(self.rtgt)
|
|
v.addWidget(H("Restore — password"))
|
|
self.rpw = PwField("If archive is encrypted"); v.addWidget(self.rpw)
|
|
self.rbtn = QPushButton("Start Restore"); self.rbtn.setObjectName("green"); self.rbtn.clicked.connect(self._restore); v.addWidget(self.rbtn)
|
|
self.rlog = Log(100); v.addWidget(self.rlog); v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
|
|
def _backup(self):
|
|
s, o = self.bsrc.path(), self.bout.path()
|
|
if not s or not o: QMessageBox.warning(self, "VaptVupt", "Set source and output."); return
|
|
cmd = ["disk", "backup"]
|
|
if self.bdedup.isChecked(): cmd.append("--dedup")
|
|
if self.bpw.text(): cmd += ["-p", self.bpw.text()]
|
|
cmd += [o, s]; run_async(self, cmd, self.bbtn, self.blog)
|
|
|
|
def _restore(self):
|
|
a, t = self.rarc.path(), self.rtgt.path()
|
|
if not a or not t: QMessageBox.warning(self, "VaptVupt", "Set archive and target."); return
|
|
SB = QMessageBox.StandardButton
|
|
if QMessageBox.warning(self, "Confirm", f"OVERWRITE {t}?", SB.Yes|SB.Cancel) != SB.Yes: return
|
|
cmd = ["disk", "restore"]
|
|
if self.rpw.text(): cmd += ["-p", self.rpw.text()]
|
|
cmd += [a, t]; run_async(self, cmd, self.rbtn, self.rlog)
|
|
|
|
|
|
class AboutTab(QWidget):
|
|
def __init__(self):
|
|
super().__init__()
|
|
inner = QWidget()
|
|
v = QVBoxLayout(inner); v.setContentsMargins(24,24,24,24); v.setSpacing(4)
|
|
for text, style in [
|
|
("VAPTVUPT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
|
|
(ZUPT_VER_NUMBER, "color:white;font-size:28px;font-weight:800;font-family:monospace;"),
|
|
("", ""),
|
|
("Post-quantum backup compression with ML-KEM-768: --pq hybrid", "color:#6a8898;font-size:13px;"),
|
|
(f"(+ X25519) or --pq-only (pure). {DEFAULT_KDF} password KDF,", "color:#6a8898;font-size:13px;"),
|
|
("block deduplication, and full-disk backup.", "color:#6a8898;font-size:13px;"),
|
|
("Renamed from Zupt in v3.0.0 (INPI Brasil trademark); .zupt", "color:#6a8898;font-size:13px;"),
|
|
("archive extension and v1.6 wire format are unchanged.", "color:#6a8898;font-size:13px;"),
|
|
("", ""),
|
|
("CRYPTOGRAPHIC STACK", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
|
|
("ML-KEM-768 FIPS 203 Post-Quantum KEM", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("X25519 RFC 7748 Elliptic Curve DH (hybrid w/ ML-KEM)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("AES-256-CTR FIPS 197 Symmetric Cipher (fresh per-block nonce)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("HMAC-SHA256 RFC 2104 Authentication (Encrypt-then-MAC)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("PBKDF2-SHA256 RFC 8018 Password KDF (default, 600k iterations)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("Argon2id RFC 9106 Password KDF (WITH_SDK=1 builds only)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("SHA3-512 FIPS 202 PQ key derivation (--pq / --pq-only)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("HKDF RFC 5869 Key Derivation Function", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("SHA3/SHAKE FIPS 202 Hash / XOF", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("XXH64 (non-crypto) Per-block checksum (inside AEAD)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("", ""),
|
|
("COMPRESSION CODEC", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
|
|
("VaptVupt LZ + ANS 2.60.4 LZ77 + tabled ANS entropy", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("AVX2 / NEON SIMD acceleration; CBMC-verified BCJ filters", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("", ""),
|
|
("CREDITS", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
|
|
("VaptVupt application Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
(" License: AGPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
(" git.securityops.co/cristiancmoises/vaptvupt", "color:#3a5868;font-size:11px;font-family:monospace;"),
|
|
("", ""),
|
|
("VaptVupt LZ + ANS codec Cristian Cezar Moisés", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
(" License: GPL-3.0-or-later (commercial license available)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
(" git.securityops.co/cristiancmoises/vaptvupt", "color:#3a5868;font-size:11px;font-family:monospace;"),
|
|
("", ""),
|
|
("WEBSITE & CONTACT", "color:#00dde0;font-size:10px;font-weight:700;letter-spacing:2px;font-family:monospace;"),
|
|
("https://zupt.securityops.co", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("sac@securityops.co (commercial licensing)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("zupt@riseup.net (general / bugs)", "color:#5a7a88;font-size:12px;font-family:monospace;"),
|
|
("", ""),
|
|
(ZUPT_VER_SHORT, "color:#3a5868;font-size:11px;font-family:monospace;"),
|
|
]:
|
|
lbl = QLabel(text)
|
|
if text == "": lbl.setFixedHeight(10)
|
|
elif style: lbl.setStyleSheet(style)
|
|
v.addWidget(lbl)
|
|
v.addStretch()
|
|
lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.addWidget(scrollable(inner))
|
|
|
|
|
|
# ── Main ──
|
|
|
|
class ZuptWindow(QMainWindow):
|
|
def __init__(self, compress_files=None, extract_file=None):
|
|
super().__init__()
|
|
self.setWindowTitle(f"VaptVupt {ZUPT_VER_NUMBER}")
|
|
self.setMinimumSize(720, 500)
|
|
self.resize(880, 640)
|
|
self.setAcceptDrops(True)
|
|
|
|
# Window icon
|
|
if ICON_PATH:
|
|
self.setWindowIcon(QIcon(ICON_PATH))
|
|
|
|
central = QWidget(); self.setCentralWidget(central)
|
|
layout = QVBoxLayout(central); layout.setContentsMargins(0,0,0,0); layout.setSpacing(0)
|
|
|
|
# Header
|
|
hdr = QFrame(); hdr.setStyleSheet("background:#050a0e;border-bottom:1px solid #1a2a30;")
|
|
hl = QHBoxLayout(hdr); hl.setContentsMargins(20,10,20,10)
|
|
title = QLabel("VAPTVUPT"); title.setStyleSheet("color:white;font-size:15px;font-weight:800;letter-spacing:3px;")
|
|
hl.addWidget(title)
|
|
sub = QLabel("Post-Quantum Backup"); sub.setStyleSheet("color:#3a5868;font-size:10px;font-weight:600;letter-spacing:1px;margin-left:8px;")
|
|
hl.addWidget(sub); hl.addStretch()
|
|
vl = QLabel(f"v{ZUPT_VER_NUMBER}"); vl.setStyleSheet("color:#3a5868;font-size:10px;font-family:monospace;background:#0a1018;padding:3px 10px;border-radius:4px;border:1px solid #1a2a30;")
|
|
hl.addWidget(vl)
|
|
layout.addWidget(hdr)
|
|
|
|
# Tabs
|
|
self.tabs = QTabWidget()
|
|
self.compress_tab = CompressTab(initial=compress_files)
|
|
self.extract_tab = ExtractTab(initial=extract_file)
|
|
self.tabs.addTab(KeysTab(), "Keys")
|
|
self.tabs.addTab(self.compress_tab, "Compress")
|
|
self.tabs.addTab(self.extract_tab, "Extract")
|
|
self.tabs.addTab(VerifyTab(), "Verify")
|
|
self.tabs.addTab(DiskTab(), "Disk")
|
|
self.tabs.addTab(AboutTab(), "About")
|
|
if compress_files: self.tabs.setCurrentIndex(1)
|
|
elif extract_file: self.tabs.setCurrentIndex(2)
|
|
layout.addWidget(self.tabs)
|
|
|
|
sb = QStatusBar()
|
|
sb.showMessage(f"VaptVupt {ZUPT_VER_NUMBER} | {VAPTVUPT}")
|
|
self.setStatusBar(sb)
|
|
|
|
def dragEnterEvent(self, e):
|
|
if e.mimeData().hasUrls(): e.acceptProposedAction()
|
|
def dropEvent(self, e):
|
|
ps = [u.toLocalFile() for u in e.mimeData().urls() if u.toLocalFile()]
|
|
if not ps: return
|
|
if ps[0].endswith(".zupt"):
|
|
self.extract_tab.arc.edit.setText(ps[0]); self.tabs.setCurrentIndex(2)
|
|
else:
|
|
self.compress_tab.src.edit.setText("|".join(ps)); self.tabs.setCurrentIndex(1)
|
|
|
|
def closeEvent(self, e):
|
|
# Join in-flight worker threads before the window goes away: kill each
|
|
# child CLI process (the worker then sees EOF and finishes) and wait
|
|
# for its QThread. Otherwise interpreter teardown collects live
|
|
# QThreads and aborts the process instead of exiting cleanly.
|
|
jobs = [j for i in range(self.tabs.count())
|
|
for j in list(getattr(self.tabs.widget(i), "_jobs", []))]
|
|
if jobs:
|
|
# Aborting mid-job can be destructive (a killed `disk restore`
|
|
# leaves the target half-written), so never do it silently.
|
|
SB = QMessageBox.StandardButton
|
|
if QMessageBox.warning(
|
|
self, "VaptVupt",
|
|
"An operation is still running.\nQuit and abort it?",
|
|
SB.Yes | SB.Cancel) != SB.Yes:
|
|
e.ignore(); return
|
|
for j in jobs:
|
|
if not j.cancel_and_join(3000):
|
|
# Thread stuck past the kill (child in D-state / pipe held by a
|
|
# grandchild). Letting teardown destroy a live QThread aborts
|
|
# with SIGABRT; exiting hard here is the clean way out.
|
|
if sys.stderr is not None:
|
|
try:
|
|
sys.stderr.write("A worker did not stop in time; "
|
|
"forcing exit.\n")
|
|
sys.stderr.flush()
|
|
except OSError:
|
|
pass
|
|
os._exit(0)
|
|
super().closeEvent(e)
|
|
|
|
|
|
def main():
|
|
args = sys.argv[1:]
|
|
|
|
# Lightweight non-GUI flags first, so `vaptvupt-gui --version|--help|--selftest`
|
|
# work with no display and aren't mistaken for files to compress. `--selftest`
|
|
# is a headless-friendly smoke test: it builds the whole UI and spins the event
|
|
# loop once, then exits 0 — the reliable way to confirm the GUI stack launches
|
|
# on a machine where the window itself is hard to see (tiling WM, remote, CI).
|
|
if args and args[0] in ("--version", "-V", "version"):
|
|
print(f"vaptvupt-gui {ZUPT_VER_NUMBER} ({QT_BINDING}) | CLI: {VAPTVUPT}")
|
|
return 0
|
|
if args and args[0] in ("--help", "-h", "help"):
|
|
print("usage: vaptvupt-gui [ARCHIVE.zupt | --extract ARCHIVE.zupt |\n"
|
|
" --compress FILE [FILE ...]]\n"
|
|
" vaptvupt-gui --selftest # verify the GUI launches (no window kept)\n"
|
|
" vaptvupt-gui --version")
|
|
return 0
|
|
|
|
compress_files = extract_file = None
|
|
selftest = ("--selftest" in args[:1])
|
|
if args and not selftest:
|
|
if args[0] == "--compress" and len(args) > 1: compress_files = args[1:]
|
|
elif args[0] == "--extract" and len(args) > 1: extract_file = args[1]
|
|
elif args[0].endswith(".zupt"): extract_file = args[0]
|
|
else: compress_files = args
|
|
|
|
app = QApplication(sys.argv)
|
|
app.setApplicationName("VaptVupt")
|
|
if ICON_PATH: app.setWindowIcon(QIcon(ICON_PATH))
|
|
app.setStyle("Fusion")
|
|
app.setStyleSheet(STYLE)
|
|
pal = QPalette()
|
|
# PyQt6 requires scoped enums (QPalette.ColorRole.Window); PySide6 supports
|
|
# both forms but we use the scoped form for cross-binding compatibility.
|
|
CR = QPalette.ColorRole
|
|
for role, c in [(CR.Window,"#0a0a0a"),(CR.WindowText,"#90acb8"),
|
|
(CR.Base,"#0e1820"),(CR.Text,"#c0dce8"),
|
|
(CR.Button,"#0a2028"),(CR.ButtonText,"#00dde0"),
|
|
(CR.Highlight,"#004858"),(CR.HighlightedText,"#00dde0")]:
|
|
pal.setColor(role, QColor(c))
|
|
app.setPalette(pal)
|
|
win = ZuptWindow(compress_files=compress_files, extract_file=extract_file)
|
|
|
|
if selftest:
|
|
win.show()
|
|
QTimer.singleShot(400, app.quit)
|
|
rc = app.exec()
|
|
print(f"selftest OK — {QT_BINDING}: window + {win.tabs.count()} tabs built, "
|
|
f"event loop ran (rc={rc}); CLI={VAPTVUPT}")
|
|
return rc
|
|
|
|
# Center + raise + focus ONLY on X11 (xcb), where a stacking WM may place
|
|
# the window off-screen or leave it unfocused. On Wayland the compositor
|
|
# owns placement and focus, and these calls (self-move / xdg restack /
|
|
# xdg-activation) SEGFAULT some Qt-Wayland builds — including PySide6 6.9 as
|
|
# shipped on Guix — so they must not run there. Plain show() is what
|
|
# --selftest exercises and is stable; the compositor maps and focuses the
|
|
# new toplevel itself. On Windows/macOS Qt's automatic placement centers
|
|
# first windows and the OS foregrounds a freshly launched app, so skipping
|
|
# is safe there too. Strict == "xcb" keeps wayland-egl etc. on the safe path.
|
|
is_x11 = app.platformName() == "xcb"
|
|
if is_x11:
|
|
scr = app.primaryScreen()
|
|
if scr is not None:
|
|
fg = win.frameGeometry()
|
|
fg.moveCenter(scr.availableGeometry().center())
|
|
win.move(fg.topLeft())
|
|
win.show()
|
|
if is_x11:
|
|
win.raise_()
|
|
win.activateWindow()
|
|
|
|
# Wayland map watchdog. On some compositor/toolkit combos (seen live on
|
|
# Sway 1.12 + Qt 6.9: a handshake deadlock where Qt never sends the initial
|
|
# wl_surface.commit, so the compositor never sends configure) the event
|
|
# loop runs but the window NEVER maps — the app looks "started" yet nothing
|
|
# appears. In that state no Expose event is ever delivered, so LATCH the
|
|
# first expose; do NOT sample isExposed() at the deadline (a healthy window
|
|
# that is merely hidden — other workspace, scratchpad, locker — reads
|
|
# unexposed ~100 ms after frame callbacks stop and would misfire). If no
|
|
# expose ever arrived, relaunch this same process on XWayland (xcb), which
|
|
# is unaffected. The sentinel env var prevents any relaunch loop (e.g.
|
|
# "-platform wayland" in argv outranks the env override and would come up
|
|
# wayland again). Nothing auto-starts jobs before the deadline, so the exec
|
|
# cannot interrupt real work. Opt out with VAPTVUPT_NO_XCB_FALLBACK=1.
|
|
if app.platformName().startswith("wayland"):
|
|
class _ExposeLatch(QObject):
|
|
exposed_once = False
|
|
def eventFilter(self, obj, ev):
|
|
if ev.type() == QEvent.Type.Expose and obj.isExposed():
|
|
self.exposed_once = True
|
|
return False
|
|
latch = _ExposeLatch()
|
|
handle = win.windowHandle()
|
|
if handle is not None:
|
|
handle.installEventFilter(latch)
|
|
def _wayland_map_check():
|
|
if latch.exposed_once or (handle is not None and handle.isExposed()):
|
|
return
|
|
can_fallback = (os.environ.get("DISPLAY")
|
|
and sys.executable
|
|
and os.environ.get("VAPTVUPT_NO_XCB_FALLBACK") != "1"
|
|
and os.environ.get("VAPTVUPT_XCB_FALLBACK_DONE") != "1")
|
|
if sys.stderr is not None:
|
|
try:
|
|
sys.stderr.write(
|
|
"Window was not exposed within 4 s (the compositor may "
|
|
"never have mapped it); "
|
|
+ ("relaunching on XWayland (xcb)...\n" if can_fallback
|
|
else "leaving the Wayland window as-is (no X11 "
|
|
"fallback: DISPLAY unset, opted out, or "
|
|
"already tried).\n"))
|
|
sys.stderr.flush()
|
|
except OSError:
|
|
pass
|
|
if can_fallback:
|
|
env = dict(os.environ, QT_QPA_PLATFORM="xcb",
|
|
VAPTVUPT_XCB_FALLBACK_DONE="1")
|
|
argv = (list(sys.argv) if getattr(sys, "frozen", False)
|
|
else [sys.executable] + sys.argv)
|
|
try:
|
|
os.execve(sys.executable, argv, env)
|
|
except OSError as exc:
|
|
if sys.stderr is not None:
|
|
try:
|
|
sys.stderr.write(f"XWayland relaunch failed ({exc});"
|
|
" window will not appear.\n")
|
|
sys.stderr.flush()
|
|
except OSError:
|
|
pass
|
|
QTimer.singleShot(4000, _wayland_map_check)
|
|
|
|
# A GUI blocks the launching shell, so a working launch otherwise looks like
|
|
# a "stuck" terminal. Emit one line to stderr so it's unambiguous. Guarded:
|
|
# PyInstaller --windowed sets sys.stderr to None (any write would raise and
|
|
# kill the window we just showed), and a dead pipe raises OSError on flush —
|
|
# a courtesy notice must never take the GUI down.
|
|
if sys.stderr is not None:
|
|
try:
|
|
sys.stderr.write(f"VaptVupt {ZUPT_VER_NUMBER} GUI started — "
|
|
f"window open (close it to exit).\n")
|
|
sys.stderr.flush()
|
|
except OSError:
|
|
pass
|
|
return app.exec()
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|