- try_reembed() now handles same-HWND reparent scenario (mstsc reconnect resets parent) - is_embedded() checks GetParent(hwnd) == parent_hwnd every 500ms - _monitor_tick() two-stage: is_alive() for process death, is_embedded() for window loss - build.py auto-cleans old releases (keep first + last 5) - Cleaned old releases from git Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
5.5 KiB
Python
194 lines
5.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Build script — creates platform-specific executables via PyInstaller.
|
|
Run on the target OS to build for that platform.
|
|
|
|
Usage:
|
|
python build.py # build for current platform
|
|
python build.py --clean # clean build artifacts first
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import sys
|
|
import shutil
|
|
import platform
|
|
|
|
# Add project root
|
|
PROJECT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, PROJECT_DIR)
|
|
|
|
|
|
def auto_bump_version() -> str:
|
|
"""Auto-increment patch version in version.py on every build."""
|
|
ver_file = os.path.join(PROJECT_DIR, "version.py")
|
|
with open(ver_file, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
match = re.search(r'__version__\s*=\s*"(\d+)\.(\d+)\.(\d+)"', content)
|
|
if not match:
|
|
print("ERROR: Cannot parse version from version.py")
|
|
sys.exit(1)
|
|
|
|
major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3))
|
|
new_patch = patch + 1
|
|
new_version = f"{major}.{minor}.{new_patch}"
|
|
|
|
content = re.sub(
|
|
r'__version__\s*=\s*"[\d.]+"',
|
|
f'__version__ = "{new_version}"',
|
|
content,
|
|
)
|
|
with open(ver_file, "w", encoding="utf-8") as f:
|
|
f.write(content)
|
|
|
|
print(f"Version bumped: {major}.{minor}.{patch} -> {new_version}")
|
|
return new_version
|
|
|
|
|
|
# Auto-bump unless --no-bump flag is passed
|
|
if "--no-bump" not in sys.argv:
|
|
_version = auto_bump_version()
|
|
else:
|
|
sys.argv.remove("--no-bump")
|
|
_version = None
|
|
|
|
from version import __version__, __app_name__
|
|
|
|
DIST_DIR = os.path.join(PROJECT_DIR, "dist")
|
|
BUILD_DIR = os.path.join(PROJECT_DIR, "build")
|
|
RELEASES_DIR = os.path.join(PROJECT_DIR, "releases")
|
|
|
|
|
|
def get_platform_tag() -> str:
|
|
system = platform.system().lower()
|
|
machine = platform.machine().lower()
|
|
|
|
arch_map = {
|
|
"x86_64": "x64", "amd64": "x64",
|
|
"x86": "x32", "i686": "x32", "i386": "x32",
|
|
"aarch64": "arm64", "arm64": "arm64",
|
|
"armv7l": "arm",
|
|
}
|
|
arch = arch_map.get(machine, machine)
|
|
|
|
os_map = {"windows": "win", "linux": "linux", "darwin": "mac"}
|
|
os_tag = os_map.get(system, system)
|
|
|
|
return f"{os_tag}-{arch}"
|
|
|
|
|
|
def clean():
|
|
for d in [DIST_DIR, BUILD_DIR]:
|
|
if os.path.exists(d):
|
|
shutil.rmtree(d)
|
|
for f in os.listdir(PROJECT_DIR):
|
|
if f.endswith(".spec"):
|
|
os.remove(os.path.join(PROJECT_DIR, f))
|
|
print("Cleaned build artifacts")
|
|
|
|
|
|
def build():
|
|
tag = get_platform_tag()
|
|
print(f"Building {__app_name__} v{__version__} for {tag}...")
|
|
|
|
system = platform.system().lower()
|
|
|
|
# PyInstaller command
|
|
cmd_parts = [
|
|
sys.executable, "-m", "PyInstaller",
|
|
"--onefile",
|
|
"--windowed",
|
|
f"--name={__app_name__}",
|
|
"--add-data", f"config/servers.example.json{os.pathsep}config",
|
|
"--add-data", f"tools/ssh.py{os.pathsep}tools",
|
|
"--add-data", f"tools/skill-ssh.md{os.pathsep}tools",
|
|
"--add-data", f"core/encryption.py{os.pathsep}core",
|
|
]
|
|
|
|
# Icon
|
|
icon_path = os.path.join(PROJECT_DIR, "assets", "icon.ico")
|
|
if os.path.exists(icon_path):
|
|
cmd_parts.extend(["--icon", icon_path])
|
|
|
|
# Hidden imports for customtkinter and connection libraries
|
|
cmd_parts.extend([
|
|
"--hidden-import", "customtkinter",
|
|
"--hidden-import", "PIL",
|
|
"--hidden-import", "pyotp",
|
|
"--hidden-import", "pyte",
|
|
"--hidden-import", "psutil",
|
|
"--hidden-import", "pymysql",
|
|
"--hidden-import", "psycopg2",
|
|
"--hidden-import", "pymssql",
|
|
"--hidden-import", "redis",
|
|
"--hidden-import", "requests",
|
|
"--hidden-import", "winrm",
|
|
"--hidden-import", "telnetlib3",
|
|
"--collect-all", "customtkinter",
|
|
])
|
|
|
|
cmd_parts.append("main.py")
|
|
|
|
os.chdir(PROJECT_DIR)
|
|
ret = os.system(" ".join(f'"{p}"' if " " in p else p for p in cmd_parts))
|
|
|
|
if ret != 0:
|
|
print(f"Build failed with code {ret}")
|
|
sys.exit(1)
|
|
|
|
# Move to releases
|
|
os.makedirs(RELEASES_DIR, exist_ok=True)
|
|
|
|
if system == "windows":
|
|
src = os.path.join(DIST_DIR, f"{__app_name__}.exe")
|
|
dst = os.path.join(RELEASES_DIR, f"{__app_name__}-v{__version__}-{tag}.exe")
|
|
elif system == "darwin":
|
|
src = os.path.join(DIST_DIR, __app_name__)
|
|
dst = os.path.join(RELEASES_DIR, f"{__app_name__}-v{__version__}-{tag}")
|
|
else:
|
|
src = os.path.join(DIST_DIR, __app_name__)
|
|
dst = os.path.join(RELEASES_DIR, f"{__app_name__}-v{__version__}-{tag}")
|
|
|
|
if os.path.exists(src):
|
|
shutil.copy2(src, dst)
|
|
size_mb = os.path.getsize(dst) / (1024 * 1024)
|
|
print(f"\nBuild complete: {dst} ({size_mb:.1f} MB)")
|
|
else:
|
|
print(f"Build output not found: {src}")
|
|
sys.exit(1)
|
|
|
|
# Auto-cleanup: keep first release + last 5 (per CLAUDE.md policy)
|
|
cleanup_old_releases()
|
|
|
|
|
|
def cleanup_old_releases():
|
|
"""Keep the first release (v1.0.0) and the last 5 releases, delete the rest."""
|
|
import glob
|
|
|
|
pattern = os.path.join(RELEASES_DIR, f"{__app_name__}-v*")
|
|
all_exes = sorted(glob.glob(pattern))
|
|
|
|
if len(all_exes) <= 6: # first + 5 = 6, nothing to clean
|
|
return
|
|
|
|
# First release is always all_exes[0] (sorted, v1.0.0 < v1.8.x)
|
|
first = all_exes[0]
|
|
last_5 = all_exes[-5:]
|
|
keep = set([first] + last_5)
|
|
|
|
removed = []
|
|
for f in all_exes:
|
|
if f not in keep:
|
|
os.remove(f)
|
|
removed.append(os.path.basename(f))
|
|
|
|
if removed:
|
|
print(f"Cleaned {len(removed)} old releases: {', '.join(removed)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--clean" in sys.argv:
|
|
clean()
|
|
build()
|