From 05a32015db65b4db027875962b51b10ec41102d2 Mon Sep 17 00:00:00 2001 From: Dr Jones Date: Fri, 22 May 2026 19:20:06 -0700 Subject: [PATCH] Add PyInstaller Windows release build system. Frozen builds resolve paths correctly, stage portable onedir output, and zip releases for distribution. --- .gitignore | 6 + README.md | 39 +++++ build/build_release.py | 322 +++++++++++++++++++++++++++++++++++++++ build/hooks/.gitkeep | 1 + build/pyi_rth_godcwak.py | 12 ++ build/version_info.txt | 36 +++++ build_release.bat | 51 +++++++ config.py | 53 +++++-- main.py | 49 ++++-- requirements-build.txt | 3 + 10 files changed, 548 insertions(+), 24 deletions(-) create mode 100644 build/build_release.py create mode 100644 build/hooks/.gitkeep create mode 100644 build/pyi_rth_godcwak.py create mode 100644 build/version_info.txt create mode 100644 build_release.bat create mode 100644 requirements-build.txt diff --git a/.gitignore b/.gitignore index 40791ab..d72600d 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,11 @@ valid_proxies.txt .env *.env +# PyInstaller / release artifacts +dist/ +release/ +build/pyinstaller/ +GODCWAK.spec + Screenshot *.png *.tmp diff --git a/README.md b/README.md index 20422e3..527b46a 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,45 @@ You can also launch directly: python main.py ``` +## Build Windows Release (PyInstaller) + +Production builds produce a portable **onedir** folder under `dist/GODCWAK/` and a zip under `release/`. + +From PowerShell in the project directory: + +```powershell +# One-time: activate venv and install build deps +.\.venv\Scripts\Activate.ps1 +pip install -r requirements-build.txt + +# Build release (no console window) +python build\build_release.py --clean + +# Troubleshooting build (shows console + logs) +python build\build_release.py --clean --debug +``` + +Or double-click `build_release.bat` at the project root. + +**Output layout** + +```text +dist/GODCWAK/ + GODCWAK.exe Main application + Start GODCWAK.bat Optional launcher + config_ollama.json Writable Ollama settings (seeded on first run) + data/ Passwords, proxies, attempt logs (created at runtime) + _internal/ Bundled Python runtime and dependencies +release/ + GODCWAK-v1.0-win64-YYYYMMDD.zip +``` + +**Notes** + +- Default release excludes CuPy/Numba/Playwright to keep the bundle smaller; use `--gpu` if CUDA libraries are installed and you want them bundled. +- Place `assets/icon.ico` before building to embed a custom application icon. +- Packaged builds log to `data/godcwak.log` and show error dialogs instead of a console window. + ## GPU Notes GODCWAK checks for CuPy at startup. With `cupy-cuda12x` installed and a working NVIDIA CUDA-capable GPU, password-combination index generation can use the GPU for larger workloads. Without CUDA, the app falls back to NumPy CPU acceleration or pure Python. diff --git a/build/build_release.py b/build/build_release.py new file mode 100644 index 0000000..628dac3 --- /dev/null +++ b/build/build_release.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +""" +Production Windows release builder for GODCWAK (PyInstaller onedir). + +Usage: + python build/build_release.py + python build/build_release.py --debug # console window for troubleshooting + python build/build_release.py --gpu # bundle CuPy/Numba if installed + python build/build_release.py --clean --no-zip +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import textwrap +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +BUILD_DIR = ROOT / "build" +DIST_DIR = ROOT / "dist" +RELEASE_DIR = ROOT / "release" +SPEC_PATH = ROOT / "GODCWAK.spec" +APP_NAME = "GODCWAK" +APP_VERSION = "1.0.0" + + +def _read_app_version() -> str: + config = ROOT / "config.py" + if not config.is_file(): + return APP_VERSION + for line in config.read_text(encoding="utf-8").splitlines(): + if line.strip().startswith("APP_VERSION"): + return line.split("=", 1)[1].strip().strip('"').strip("'") + return APP_VERSION + + +def _python_exe() -> str: + return sys.executable + + +def _ensure_pyinstaller() -> None: + try: + import PyInstaller # noqa: F401 + except ImportError as exc: + raise SystemExit( + "PyInstaller is not installed. Run:\n" + f" {_python_exe()} -m pip install -r requirements-build.txt" + ) from exc + + +def _write_spec(*, console: bool, include_gpu: bool, icon: Path | None) -> None: + version_file = BUILD_DIR / "version_info.txt" + exe_kwargs = [] + if version_file.is_file(): + exe_kwargs.append("version=str(ROOT / 'build' / 'version_info.txt'),") + if icon: + exe_kwargs.append("icon=str(ROOT / 'assets' / 'icon.ico'),") + exe_extra = "\n ".join(exe_kwargs) + + gpu_block = "" + if not include_gpu: + gpu_block = """ +excludes += [ + "cupy", + "cupy_backends", + "numba", + "llvmlite", +] +""" + + lines = [ + "# -*- mode: python ; coding: utf-8 -*-", + "# Generated by build/build_release.py — do not edit by hand.", + "from pathlib import Path", + "", + "from PyInstaller.utils.hooks import collect_all, collect_submodules", + "", + "block_cipher = None", + f'ROOT = Path(r"{ROOT.as_posix()}")', + "", + 'ctk_datas, ctk_binaries, ctk_hidden = collect_all("customtkinter")', + 'dark_datas, dark_binaries, dark_hidden = collect_all("darkdetect")', + "", + "datas = [", + ' (str(ROOT / "config_ollama.json"), "."),', + "]", + "assets = ROOT / \"assets\"", + "if assets.is_dir():", + ' datas.append((str(assets), "assets"))', + "", + "datas += ctk_datas + dark_datas", + "binaries = ctk_binaries + dark_binaries", + "", + "hiddenimports = [", + ' "PIL._tkinter_finder",', + ' "PIL.Image",', + ' "PIL.ImageTk",', + ' "aiohttp",', + ' "aiohttp_socks",', + ' "multidict",', + ' "yarl",', + ' "frozenlist",', + ' "aiosignal",', + ' "async_timeout",', + ' "charset_normalizer",', + ' "certifi",', + ' "socks",', + ' "urllib3",', + ' "requests",', + ' "numpy",', + ' "darkdetect",', + ' "config",', + ' "main",', + "]", + "hiddenimports += ctk_hidden + dark_hidden", + 'hiddenimports += collect_submodules("src")', + "", + "excludes = [", + ' "playwright",', + ' "matplotlib",', + ' "scipy",', + ' "pandas",', + ' "pytest",', + ' "IPython",', + ' "jupyter",', + ' "tkinter.test",', + "]", + gpu_block.rstrip(), + "", + "a = Analysis(", + ' [str(ROOT / "main.py")],', + " pathex=[str(ROOT)],", + " binaries=binaries,", + " datas=datas,", + " hiddenimports=hiddenimports,", + ' hookspath=[str(ROOT / "build" / "hooks")],', + " hooksconfig={},", + ' runtime_hooks=[str(ROOT / "build" / "pyi_rth_godcwak.py")],', + " excludes=excludes,", + " win_no_prefer_redirects=False,", + " win_private_assemblies=False,", + " cipher=block_cipher,", + " noarchive=False,", + ")", + "", + "pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)", + "", + "exe = EXE(", + " pyz,", + " a.scripts,", + " [],", + " exclude_binaries=True,", + f' name="{APP_NAME}",', + " debug=False,", + " bootloader_ignore_signals=False,", + " strip=False,", + " upx=True,", + f" console={console},", + " disable_windowed_traceback=False,", + " argv_emulation=False,", + " target_arch=None,", + " codesign_identity=None,", + " entitlements_file=None,", + ] + if exe_extra: + lines.append(f" {exe_extra}") + lines.extend([ + ")", + "", + "coll = COLLECT(", + " exe,", + " a.binaries,", + " a.zipfiles,", + " a.datas,", + " strip=False,", + " upx=True,", + " upx_exclude=[],", + f' name="{APP_NAME}",', + ")", + "", + ]) + SPEC_PATH.write_text("\n".join(lines), encoding="utf-8") + + +def _run_pyinstaller(*, clean: bool) -> None: + cmd = [ + _python_exe(), + "-m", + "PyInstaller", + "--noconfirm", + str(SPEC_PATH), + "--distpath", + str(DIST_DIR), + "--workpath", + str(BUILD_DIR / "pyinstaller"), + ] + if clean: + cmd.insert(4, "--clean") + print("Running:", " ".join(cmd)) + subprocess.run(cmd, cwd=str(ROOT), check=True) + + +def _stage_release(app_dir: Path, version: str, *, console: bool) -> None: + """Copy user-facing files next to the exe (writable config/data live here).""" + app_dir.mkdir(parents=True, exist_ok=True) + + config_dst = app_dir / "config_ollama.json" + if not config_dst.exists(): + shutil.copy2(ROOT / "config_ollama.json", config_dst) + + data_dir = app_dir / "data" + data_dir.mkdir(exist_ok=True) + readme = data_dir / "README.txt" + if not readme.exists(): + readme.write_text( + "GODCWAK stores passwords, proxies, and attempt logs in this folder.\n" + "Keep this directory backed up and out of version control.\n", + encoding="utf-8", + ) + + launcher = app_dir / "Start GODCWAK.bat" + launcher.write_text( + textwrap.dedent( + f"""\ + @echo off + title {APP_NAME} + cd /d "%~dp0" + start "" "{APP_NAME}.exe" + """ + ), + encoding="utf-8", + ) + + manifest = { + "app": APP_NAME, + "version": version, + "built_at": datetime.now(timezone.utc).isoformat(), + "platform": "win64", + "python": sys.version.split()[0], + "console": console, + } + (app_dir / "build_manifest.json").write_text( + json.dumps(manifest, indent=2), + encoding="utf-8", + ) + + +def _create_zip(app_dir: Path, version: str) -> Path: + RELEASE_DIR.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(timezone.utc).strftime("%Y%m%d") + zip_path = RELEASE_DIR / f"{APP_NAME}-v{version}-win64-{stamp}.zip" + if zip_path.exists(): + zip_path.unlink() + + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf: + for path in sorted(app_dir.rglob("*")): + if path.is_file(): + zf.write(path, path.relative_to(app_dir.parent).as_posix()) + return zip_path + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build GODCWAK Windows release with PyInstaller") + parser.add_argument( + "--debug", + action="store_true", + help="Build with a console window (for troubleshooting)", + ) + parser.add_argument( + "--gpu", + action="store_true", + help="Include CuPy/Numba in the bundle if installed (much larger build)", + ) + parser.add_argument( + "--clean", + action="store_true", + help="Clean PyInstaller cache before building", + ) + parser.add_argument( + "--no-zip", + action="store_true", + help="Skip creating release/*.zip archive", + ) + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + version = _read_app_version() + icon = ROOT / "assets" / "icon.ico" + + os.chdir(ROOT) + _ensure_pyinstaller() + + print(f"Building {APP_NAME} v{version} (console={'yes' if args.debug else 'no'}, gpu={'yes' if args.gpu else 'no'})") + _write_spec(console=args.debug, include_gpu=args.gpu, icon=icon if icon.is_file() else None) + _run_pyinstaller(clean=args.clean) + + app_dir = DIST_DIR / APP_NAME + if not (app_dir / f"{APP_NAME}.exe").is_file(): + raise SystemExit(f"Build failed: missing {app_dir / f'{APP_NAME}.exe'}") + + _stage_release(app_dir, version, console=args.debug) + + print(f"\nBuild complete: {app_dir}") + print(f"Run: {app_dir / f'{APP_NAME}.exe'}") + + if not args.no_zip: + zip_path = _create_zip(app_dir, version) + print(f"Release archive: {zip_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/build/hooks/.gitkeep b/build/hooks/.gitkeep new file mode 100644 index 0000000..25504cf --- /dev/null +++ b/build/hooks/.gitkeep @@ -0,0 +1 @@ +# PyInstaller hook search path (optional custom hooks) diff --git a/build/pyi_rth_godcwak.py b/build/pyi_rth_godcwak.py new file mode 100644 index 0000000..6f17f47 --- /dev/null +++ b/build/pyi_rth_godcwak.py @@ -0,0 +1,12 @@ +""" +PyInstaller runtime hook — normalize frozen paths before application imports. +""" +import os +import sys + +if getattr(sys, "frozen", False): + os.environ.setdefault("GODCWAK_FROZEN", "1") + # Ensure the exe directory is on PATH for any subprocess helpers. + exe_dir = os.path.dirname(sys.executable) + if exe_dir and exe_dir not in os.environ.get("PATH", ""): + os.environ["PATH"] = exe_dir + os.pathsep + os.environ.get("PATH", "") diff --git a/build/version_info.txt b/build/version_info.txt new file mode 100644 index 0000000..314b681 --- /dev/null +++ b/build/version_info.txt @@ -0,0 +1,36 @@ +# UTF-8 +# +# Windows version resource for GODCWAK.exe +# +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3f, + flags=0x0, + OS=0x40004, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + '040904B0', + [ + StringStruct('CompanyName', 'GODCWAK'), + StringStruct('FileDescription', 'GODCWAK — GPU-accelerated recovery lab tool'), + StringStruct('FileVersion', '1.0.0.0'), + StringStruct('InternalName', 'GODCWAK'), + StringStruct('LegalCopyright', 'Copyright (c) 2026'), + StringStruct('OriginalFilename', 'GODCWAK.exe'), + StringStruct('ProductName', 'GODCWAK'), + StringStruct('ProductVersion', '1.0.0.0'), + ], + ) + ] + ), + VarFileInfo([VarStruct('Translation', [1033, 1200])]), + ] +) diff --git a/build_release.bat b/build_release.bat new file mode 100644 index 0000000..255b412 --- /dev/null +++ b/build_release.bat @@ -0,0 +1,51 @@ +@echo off +setlocal EnableExtensions +title GODCWAK Release Build +color 0B + +cd /d "%~dp0\.." + +echo. +echo ========================================= +echo GODCWAK - Windows Release Build +echo ========================================= +echo. + +set PYTHON_EXE= +set PYTHON_ARGS= +if exist ".venv\Scripts\python.exe" ( + set "PYTHON_EXE=.venv\Scripts\python.exe" +) else ( + py -3 --version >nul 2>&1 + if %ERRORLEVEL% EQU 0 ( + set "PYTHON_EXE=py" + set "PYTHON_ARGS=-3" + ) else ( + set "PYTHON_EXE=python" + ) +) + +echo Using: %PYTHON_EXE% %PYTHON_ARGS% +echo. + +"%PYTHON_EXE%" %PYTHON_ARGS% -m pip install -q -r requirements-build.txt +if %ERRORLEVEL% NEQ 0 ( + echo [ERROR] Failed to install build dependencies. + pause + exit /b 1 +) + +"%PYTHON_EXE%" %PYTHON_ARGS% build\build_release.py --clean %* +set BUILD_EXIT=%ERRORLEVEL% + +echo. +if %BUILD_EXIT% EQU 0 ( + echo [OK] Release build finished. + echo Output: dist\GODCWAK\ + echo Archive: release\ +) else ( + echo [ERROR] Build failed with exit code %BUILD_EXIT% +) +echo. +pause +exit /b %BUILD_EXIT% diff --git a/config.py b/config.py index 172480f..2c3a626 100644 --- a/config.py +++ b/config.py @@ -1,25 +1,35 @@ """ Configuration and constants for GODCWAK. """ -import os +import json +import shutil +import sys from pathlib import Path APP_NAME = "GODCWAK" APP_VERSION = "1.0" -# Paths -BASE_DIR = Path(__file__).parent + +def _is_frozen() -> bool: + return getattr(sys, "frozen", False) + + +def _exe_dir() -> Path: + return Path(sys.executable).resolve().parent + + +def _bundle_dir() -> Path: + if _is_frozen() and hasattr(sys, "_MEIPASS"): + return Path(sys._MEIPASS) + return Path(__file__).resolve().parent + + +# Writable root: next to the exe when frozen, project root in dev +BASE_DIR = _exe_dir() if _is_frozen() else Path(__file__).resolve().parent +BUNDLE_DIR = _bundle_dir() DATA_DIR = BASE_DIR / "data" -ASSETS_DIR = BASE_DIR / "assets" - -# bug #35: removed DATA_DIR.mkdir() side effect from module level. -# Call ensure_dirs() explicitly at startup instead. - - -def ensure_dirs(): - """Create required application directories. Call once at startup.""" - DATA_DIR.mkdir(exist_ok=True) - ASSETS_DIR.mkdir(exist_ok=True) +_assets_bundle = BUNDLE_DIR / "assets" +ASSETS_DIR = _assets_bundle if _assets_bundle.is_dir() else BASE_DIR / "assets" # Facebook @@ -69,6 +79,23 @@ DEFAULT_OLLAMA_CONFIG = { "retry_limit": 3, } + +def ensure_dirs(): + """Create writable dirs and seed default Ollama config for packaged builds.""" + DATA_DIR.mkdir(exist_ok=True) + (BASE_DIR / "assets").mkdir(exist_ok=True) + + if not OLLAMA_CONFIG_FILE.exists(): + bundled = BUNDLE_DIR / "config_ollama.json" + if bundled.is_file(): + shutil.copy2(bundled, OLLAMA_CONFIG_FILE) + else: + OLLAMA_CONFIG_FILE.write_text( + json.dumps(DEFAULT_OLLAMA_CONFIG, indent=2), + encoding="utf-8", + ) + + # GUI Theme Colors THEME = { "bg_dark": "#0a0a0f", diff --git a/main.py b/main.py index 8a99d41..28169be 100644 --- a/main.py +++ b/main.py @@ -11,23 +11,51 @@ import logging import sys import os -# Add project root to path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# Project root on sys.path when running from source (PyInstaller bundles modules directly) +if not getattr(sys, "frozen", False): + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from config import ensure_dirs, DATA_DIR, APP_NAME, APP_VERSION -# bug #35: ensure data directories exist before any module tries to write them -from config import ensure_dirs ensure_dirs() -# Configure logging +_handlers = [logging.StreamHandler()] +if getattr(sys, "frozen", False): + _log_file = DATA_DIR / "godcwak.log" + _handlers.append(logging.FileHandler(_log_file, encoding="utf-8")) + logging.basicConfig( level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s", datefmt="%H:%M:%S", + handlers=_handlers, ) logger = logging.getLogger(__name__) +def _fatal(message: str, exc: Exception | None = None): + """Show a visible error in dev (console) and in frozen builds (dialog + log file).""" + if exc is not None: + logger.error(message, exc_info=exc) + else: + logger.error(message) + if getattr(sys, "frozen", False): + try: + import tkinter as tk + from tkinter import messagebox + + root = tk.Tk() + root.withdraw() + messagebox.showerror(APP_NAME, message) + root.destroy() + except Exception: + pass + else: + input("\nPress Enter to exit...") + sys.exit(1) + + def check_dependencies(): """Check that critical dependencies are available.""" missing = [] @@ -83,12 +111,13 @@ def check_dependencies(): def main(): """Application entry point.""" logger.info("=" * 60) - logger.info(" GODCWAK v1.0") + logger.info(f" {APP_NAME} v{APP_VERSION}") + if getattr(sys, "frozen", False): + logger.info(" (packaged build)") logger.info("=" * 60) if not check_dependencies(): - input("\nPress Enter to exit...") - sys.exit(1) + _fatal("Missing required dependencies. Reinstall the application or run pip install -r requirements.txt") try: from src.gui.app import GODCWAKApp @@ -98,9 +127,7 @@ def main(): app.mainloop() except Exception as e: - logger.error(f"Fatal error: {e}", exc_info=True) - input("\nPress Enter to exit...") - sys.exit(1) + _fatal(f"Fatal error: {e}", exc=e) if __name__ == "__main__": diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..bac05dd --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,3 @@ +# Windows release build toolchain (install into .venv alongside requirements.txt) +-r requirements.txt +pyinstaller>=6.3.0