All checks were successful
Windows project source guards / test (push) Successful in 3m53s
- Server list on QSortFilterProxyModel with diff updates, batched ping results, compact 30px rows, configurable persisted columns and sorting - Faster node switching: instant kill instead of terminate timeout, socket probes instead of netstat, ctypes adapter polling, batched route commands, mtime config cache, generator-based hot-swap transitions (TransitionRunner) - Subscriptions: fetch/parse/reconcile services with UI page and QR import Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
235 lines
7.6 KiB
Python
235 lines
7.6 KiB
Python
"""
|
|
Build XrayFluent portable exe via PyInstaller.
|
|
|
|
Usage: python build.py — full build (clean + compile + pack zip)
|
|
python build.py --no-zip — skip zip creation
|
|
python build.py --clean — only wipe previous build artefacts
|
|
|
|
Requires .venv created by setup.bat (or manually).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent
|
|
VENV_DIR = ROOT / ".venv"
|
|
VENV_PYTHON = VENV_DIR / "Scripts" / "python.exe"
|
|
VENV_PIP = VENV_DIR / "Scripts" / "pip.exe"
|
|
|
|
APP_NAME = "ZapretKVN"
|
|
|
|
DIST_DIR = ROOT / "dist"
|
|
BUILD_DIR = ROOT / "build"
|
|
APP_DIR = DIST_DIR / APP_NAME
|
|
ZIP_PATH = DIST_DIR / f"{APP_NAME}-portable.zip"
|
|
|
|
MANIFEST = ROOT / "uac_admin.manifest"
|
|
CORE_DIR = ROOT / "core"
|
|
ZAPRET_DIR = ROOT / "zapret"
|
|
DATA_TEMPLATES_DIR = ROOT / "data" / "templates"
|
|
ASSETS_DIR = ROOT / "assets"
|
|
APP_ICON = ASSETS_DIR / "app_icon.ico"
|
|
TEMPLATE_UPDATE_BUNDLE_NAME = "template-update"
|
|
|
|
|
|
def _print(msg: str) -> None:
|
|
print(f"[build] {msg}", flush=True)
|
|
|
|
|
|
def _windows_path(path: Path) -> str:
|
|
"""Convert a repo path to a Windows path when running via WSL interop."""
|
|
resolved = path.resolve()
|
|
if os.name == "nt":
|
|
return str(resolved)
|
|
result = subprocess.run(
|
|
["wslpath", "-w", str(resolved)],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return result.stdout.strip()
|
|
|
|
|
|
def _run(cmd: list[str], **kwargs) -> None:
|
|
_print(f"> {' '.join(cmd)}")
|
|
subprocess.run(cmd, check=True, **kwargs)
|
|
|
|
|
|
def _copy_tree_merge(src: Path, dst: Path) -> None:
|
|
"""Copy src tree into dst, overwriting files where possible and skipping locked ones."""
|
|
dst.mkdir(parents=True, exist_ok=True)
|
|
for item in src.iterdir():
|
|
target = dst / item.name
|
|
if item.is_dir():
|
|
_copy_tree_merge(item, target)
|
|
else:
|
|
try:
|
|
shutil.copy2(str(item), str(target))
|
|
except PermissionError:
|
|
_print(f" skipped (locked): {target.name}")
|
|
|
|
|
|
def stage_template_update_bundle(
|
|
source_dir: Path = DATA_TEMPLATES_DIR,
|
|
app_dir: Path = APP_DIR,
|
|
) -> Path:
|
|
"""Mirror versioned native JSON templates outside preserved data/."""
|
|
destination = app_dir / "assets" / TEMPLATE_UPDATE_BUNDLE_NAME
|
|
if destination.exists():
|
|
shutil.rmtree(destination)
|
|
if source_dir.is_dir():
|
|
_print(f"Staging template update bundle -> {destination}")
|
|
_copy_tree_merge(source_dir, destination)
|
|
return destination
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
def ensure_venv() -> None:
|
|
if VENV_PYTHON.exists():
|
|
_print(f"venv OK: {VENV_PYTHON}")
|
|
return
|
|
_print("Creating virtual environment ...")
|
|
_run([sys.executable, "-m", "venv", str(VENV_DIR)])
|
|
_run([str(VENV_PIP), "install", "--upgrade", "pip"])
|
|
_run([str(VENV_PIP), "install", "-r", str(ROOT / "requirements.txt")])
|
|
|
|
|
|
def clean() -> None:
|
|
# build/ is purely temporary — safe to nuke
|
|
if BUILD_DIR.exists():
|
|
_print(f"Removing {BUILD_DIR}")
|
|
try:
|
|
shutil.rmtree(BUILD_DIR)
|
|
except PermissionError:
|
|
_print(f"ERROR: Cannot remove {BUILD_DIR} — is XrayFluent.exe still running?")
|
|
_print("Close the app (tray -> Quit) and try again.")
|
|
raise SystemExit(1)
|
|
|
|
# dist/XrayFluent/ — remove everything EXCEPT data/, core/, zapret/
|
|
# core/ and zapret/ are kept because running binaries (xray.exe) lock them;
|
|
# they will be merged/overwritten in build_exe() instead.
|
|
keep_dirs = {"data", "core", "zapret"}
|
|
if APP_DIR.exists():
|
|
for child in APP_DIR.iterdir():
|
|
if child.name in keep_dirs:
|
|
_print(f"Keeping {child}")
|
|
continue
|
|
try:
|
|
if child.is_dir():
|
|
shutil.rmtree(child)
|
|
else:
|
|
child.unlink()
|
|
except PermissionError:
|
|
_print(f"WARNING: Cannot remove {child}, skipping")
|
|
_print(f"Cleaned {APP_DIR} (data/, core/, zapret/ preserved)")
|
|
|
|
|
|
def build_exe() -> None:
|
|
ensure_venv()
|
|
|
|
# Build into a temporary directory so PyInstaller doesn't touch the live
|
|
# APP_DIR (which may contain locked files like running xray.exe).
|
|
temp_dist = DIST_DIR / "_build_tmp"
|
|
if temp_dist.exists():
|
|
shutil.rmtree(temp_dist)
|
|
|
|
cmd = [
|
|
str(VENV_PYTHON), "-m", "PyInstaller",
|
|
_windows_path(ROOT / "main.py"),
|
|
"--name", APP_NAME,
|
|
"--noconfirm",
|
|
"--console",
|
|
"--onedir",
|
|
"--uac-admin",
|
|
"--manifest", _windows_path(MANIFEST),
|
|
"--icon", _windows_path(APP_ICON),
|
|
"--distpath", _windows_path(temp_dist),
|
|
# win32comext is needed by qframelesswindow for Mica/DWM effects
|
|
"--hidden-import", "win32comext",
|
|
"--hidden-import", "win32comext.shell",
|
|
"--hidden-import", "win32comext.shell.shellcon",
|
|
# encodings.idna is needed by socket.getaddrinfo() for hostname resolution
|
|
"--hidden-import", "encodings.idna",
|
|
# Imported lazily by QR support and therefore must be explicit for PyInstaller.
|
|
"--hidden-import", "zxingcpp",
|
|
]
|
|
_run(cmd, cwd=str(ROOT))
|
|
|
|
# Merge PyInstaller output into the real APP_DIR (skip locked files)
|
|
temp_app = temp_dist / APP_NAME
|
|
_print(f"Merging build output -> {APP_DIR}")
|
|
_copy_tree_merge(temp_app, APP_DIR)
|
|
shutil.rmtree(temp_dist, ignore_errors=True)
|
|
|
|
# Copy core/ into dist (merge, skip locked files like running xray.exe)
|
|
dst_core = APP_DIR / "core"
|
|
_print(f"Merging core -> {dst_core}")
|
|
_copy_tree_merge(CORE_DIR, dst_core)
|
|
|
|
# Copy zapret/ into dist (merge, skip locked files)
|
|
dst_zapret = APP_DIR / "zapret"
|
|
if ZAPRET_DIR.is_dir():
|
|
_print(f"Merging zapret -> {dst_zapret}")
|
|
_copy_tree_merge(ZAPRET_DIR, dst_zapret)
|
|
|
|
# Copy tracked raw config templates for first-run users
|
|
dst_templates = APP_DIR / "data" / "templates"
|
|
if DATA_TEMPLATES_DIR.is_dir():
|
|
_print(f"Merging templates -> {dst_templates}")
|
|
_copy_tree_merge(DATA_TEMPLATES_DIR, dst_templates)
|
|
|
|
# Keep the high-resolution PNG available to Qt for the window, splash,
|
|
# and tray while the multi-size ICO is embedded into the executable.
|
|
dst_assets = APP_DIR / "assets"
|
|
if ASSETS_DIR.is_dir():
|
|
_print(f"Merging assets -> {dst_assets}")
|
|
_copy_tree_merge(ASSETS_DIR, dst_assets)
|
|
|
|
# app_updater.py preserves the installed data/ directory. Carry a second,
|
|
# generated copy outside data/ so the updated executable can safely merge
|
|
# shipped templates and untouched active configs on first launch.
|
|
stage_template_update_bundle()
|
|
|
|
_print(f"Build complete: {APP_DIR / (APP_NAME + '.exe')}")
|
|
|
|
|
|
def pack_zip() -> None:
|
|
if ZIP_PATH.exists():
|
|
ZIP_PATH.unlink()
|
|
_print(f"Creating {ZIP_PATH} ...")
|
|
shutil.make_archive(str(ZIP_PATH.with_suffix("")), "zip", str(DIST_DIR), APP_NAME)
|
|
_print(f"Portable archive ready: {ZIP_PATH}")
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Build XrayFluent portable exe")
|
|
parser.add_argument("--no-zip", action="store_true", help="skip zip creation")
|
|
parser.add_argument("--clean", action="store_true", help="only clean build artefacts")
|
|
args = parser.parse_args()
|
|
|
|
os.chdir(ROOT)
|
|
|
|
if args.clean:
|
|
clean()
|
|
_print("Done.")
|
|
return 0
|
|
|
|
clean()
|
|
build_exe()
|
|
|
|
if not args.no_zip:
|
|
pack_zip()
|
|
|
|
_print("All done!")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|