Update build script
This commit is contained in:
+304
-48
@@ -7,11 +7,16 @@ CLI standard: vedi `python build_plugin.py --help`
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import errno
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Ensure UTF-8 output on Windows (avoids cp1252 UnicodeEncodeError)
|
# Ensure UTF-8 output on Windows (avoids cp1252 UnicodeEncodeError)
|
||||||
@@ -34,62 +39,214 @@ CONFIGS = {
|
|||||||
VCXPROJ = SRC_DIR / "ShellExecAsUser.vcxproj"
|
VCXPROJ = SRC_DIR / "ShellExecAsUser.vcxproj"
|
||||||
DLL_NAME = "ShellExecAsUser.dll"
|
DLL_NAME = "ShellExecAsUser.dll"
|
||||||
|
|
||||||
|
_print_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Utilities (copied verbatim from nsInnoUnp/build_plugin.py)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _on_rmtree_error(func, path, exc_info):
|
||||||
|
"""Error handler for shutil.rmtree to handle read-only files and temporary locks on Windows."""
|
||||||
|
if func in (os.unlink, os.rmdir) and exc_info[1].errno == errno.EACCES:
|
||||||
|
try:
|
||||||
|
os.chmod(path, stat.S_IWRITE)
|
||||||
|
func(path)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for i in range(5):
|
||||||
|
try:
|
||||||
|
time.sleep(0.1 * (i + 1))
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
os.unlink(path)
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise exc_info[1]
|
||||||
|
|
||||||
|
|
||||||
|
def robust_rmtree(path) -> None:
|
||||||
|
"""Robustly remove a directory tree, handling Windows file locking issues."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path, onexc=_on_rmtree_error)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path, onerror=_on_rmtree_error)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Colors:
|
||||||
|
CYAN = "\033[36m"
|
||||||
|
GREEN = "\033[32m"
|
||||||
|
YELLOW = "\033[33m"
|
||||||
|
RED = "\033[31m"
|
||||||
|
GRAY = "\033[90m"
|
||||||
|
BLUE = "\033[34m"
|
||||||
|
MAGENTA = "\033[35m"
|
||||||
|
RESET = "\033[0m"
|
||||||
|
BOLD = "\033[1m"
|
||||||
|
BRIGHT_GREEN = "\033[92m"
|
||||||
|
BRIGHT_CYAN = "\033[96m"
|
||||||
|
BRIGHT_WHITE = "\033[97m"
|
||||||
|
BRIGHT_YELLOW = "\033[93m"
|
||||||
|
BRIGHT_MAGENTA = "\033[95m"
|
||||||
|
BRIGHT_RED = "\033[91m"
|
||||||
|
|
||||||
|
|
||||||
|
class Spinner:
|
||||||
|
"""A colored terminal spinner with elapsed time display."""
|
||||||
|
def __init__(self, message: str = "Building...", delay: float = 0.1):
|
||||||
|
self.spinner = ['\u280b', '\u2819', '\u2839', '\u2838', '\u283c', '\u2834', '\u2826', '\u2827', '\u2807', '\u280f']
|
||||||
|
self.delay = delay
|
||||||
|
self.message = message
|
||||||
|
self.running = False
|
||||||
|
self.thread = None
|
||||||
|
self.start_time = time.time()
|
||||||
|
|
||||||
|
def _spin(self):
|
||||||
|
idx = 0
|
||||||
|
while self.running:
|
||||||
|
elapsed = time.time() - self.start_time
|
||||||
|
time_blocks = f"{Colors.YELLOW}{'\u28ff' * int(elapsed // 2)}{Colors.RESET}"
|
||||||
|
spin_char = f"{Colors.YELLOW}{self.spinner[idx % len(self.spinner)]}{Colors.RESET}"
|
||||||
|
msg = f"{Colors.BOLD}{Colors.CYAN}{self.message}{Colors.RESET}"
|
||||||
|
time_str = f"{Colors.GREEN}{int(elapsed)}s{Colors.RESET}"
|
||||||
|
sys.stdout.write(f"\r{msg} {time_str} {time_blocks}{spin_char} ")
|
||||||
|
sys.stdout.flush()
|
||||||
|
idx += 1
|
||||||
|
time.sleep(self.delay)
|
||||||
|
sys.stdout.write("\r" + " " * (len(self.message) + 80) + "\r")
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
if sys.stdout.isatty():
|
||||||
|
self.running = True
|
||||||
|
self.start_time = time.time()
|
||||||
|
self.thread = threading.Thread(target=self._spin, daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if self.running:
|
||||||
|
self.running = False
|
||||||
|
if self.thread:
|
||||||
|
self.thread.join()
|
||||||
|
|
||||||
|
|
||||||
|
def format_time(seconds: float) -> str:
|
||||||
|
if seconds < 60:
|
||||||
|
return f"{seconds:.2f}s"
|
||||||
|
m = int(seconds // 60)
|
||||||
|
s = seconds - m * 60
|
||||||
|
if m < 60:
|
||||||
|
return f"{m}m {s:.1f}s"
|
||||||
|
h = m // 60
|
||||||
|
m = m % 60
|
||||||
|
return f"{h}h {m}m {int(s)}s"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Build logic
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
def read_version() -> str:
|
def read_version() -> str:
|
||||||
# utf-8-sig strips BOM (\ufeff) automatically
|
# utf-8-sig strips BOM (\ufeff) automatically
|
||||||
return VERSION_FILE.read_text(encoding="utf-8-sig").strip()
|
return VERSION_FILE.read_text(encoding="utf-8-sig").strip()
|
||||||
|
|
||||||
|
|
||||||
def find_msbuild(toolset: str) -> Path:
|
_VS_VERSION_RANGE = {'2026': '[18.0,19.0)', '2022': '[17.0,18.0)'}
|
||||||
"""Localizza MSBuild via vswhere; toolset='2022', '2026', o 'auto'.
|
_VS_TOOLSET = {'2026': 'v145', '2022': 'v143'}
|
||||||
|
_VSWHERE = (
|
||||||
In auto mode, tries 2022 before 2026 because most vcxproj files use
|
Path(os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"))
|
||||||
PlatformToolset v143 (VS2022) which may not be available in VS2026 build tools.
|
/ "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
|
||||||
"""
|
)
|
||||||
pf86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)")
|
|
||||||
vswhere = Path(pf86) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe"
|
|
||||||
if not vswhere.exists():
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
def _query(version_range: str | None) -> list[str]:
|
|
||||||
args = [str(vswhere), "-latest", "-products", "*",
|
|
||||||
"-requires", "Microsoft.Component.MSBuild",
|
|
||||||
"-find", r"MSBuild\**\Bin\MSBuild.exe"]
|
|
||||||
if version_range:
|
|
||||||
args += ["-version", version_range]
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True)
|
|
||||||
return [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
|
|
||||||
|
|
||||||
if toolset == "2022":
|
|
||||||
paths = _query("[17.0,18.0)")
|
|
||||||
elif toolset == "2026":
|
|
||||||
paths = _query("[18.0,19.0)")
|
|
||||||
else: # auto: prefer 2022 (v143) over 2026 (v145)
|
|
||||||
paths = _query("[17.0,18.0)") or _query("[18.0,19.0)") or _query(None)
|
|
||||||
|
|
||||||
if not paths:
|
|
||||||
sys.exit(2)
|
|
||||||
return Path(paths[0])
|
|
||||||
|
|
||||||
|
|
||||||
def build_one(msbuild: Path, config: str, platform: str, jobs: int, verbose: bool) -> bool:
|
def find_msbuild(vs_version: str = 'auto') -> tuple[Path, str, str] | None:
|
||||||
|
"""Locate MSBuild via vswhere, returning (path, toolset, vs_year) or None."""
|
||||||
|
if _VSWHERE.exists():
|
||||||
|
versions_to_try = ['2022', '2026'] if vs_version == 'auto' else [vs_version]
|
||||||
|
for ver in versions_to_try:
|
||||||
|
if ver not in _VS_VERSION_RANGE:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[str(_VSWHERE), '-version', _VS_VERSION_RANGE[ver], '-latest',
|
||||||
|
'-requires', 'Microsoft.Component.MSBuild',
|
||||||
|
'-find', r'MSBuild\**\Bin\MSBuild.exe'],
|
||||||
|
capture_output=True, text=True, timeout=15,
|
||||||
|
)
|
||||||
|
lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
|
||||||
|
if lines:
|
||||||
|
p = Path(lines[0])
|
||||||
|
if p.exists():
|
||||||
|
return p, _VS_TOOLSET[ver], ver
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
# Fallback to well-known paths
|
||||||
|
versions = ['2022', '2026'] if vs_version == 'auto' else [vs_version]
|
||||||
|
for ver in versions:
|
||||||
|
for ed in ('Community', 'Professional', 'Enterprise', 'BuildTools'):
|
||||||
|
p = Path(f'C:/Program Files/Microsoft Visual Studio/{ver}/{ed}/MSBuild/Current/Bin/MSBuild.exe')
|
||||||
|
if p.exists():
|
||||||
|
return p, _VS_TOOLSET.get(ver, 'v143'), ver
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_plugins_dir() -> Path | None:
|
||||||
|
"""Return workspace-level plugins/ dir (3 levels above src/modules/<plugin>/)."""
|
||||||
|
p = ROOT.parent.parent.parent / 'plugins'
|
||||||
|
return p if p.is_dir() else None
|
||||||
|
|
||||||
|
|
||||||
|
def clean_build() -> None:
|
||||||
|
"""Remove dist/ and all MSBuild output directories."""
|
||||||
|
for d in [
|
||||||
|
DIST_DIR,
|
||||||
|
SRC_DIR / "Release",
|
||||||
|
SRC_DIR / "Release Unicode",
|
||||||
|
SRC_DIR / "x64",
|
||||||
|
SRC_DIR / "Debug",
|
||||||
|
ROOT / "obj",
|
||||||
|
]:
|
||||||
|
if d.exists():
|
||||||
|
robust_rmtree(d)
|
||||||
|
|
||||||
|
|
||||||
|
def build_one(msbuild: Path, msbuild_config: str, platform: str,
|
||||||
|
jobs: int, verbose: bool, rebuild: bool) -> tuple[bool, float]:
|
||||||
|
"""Build one configuration, returning (success, elapsed_seconds)."""
|
||||||
|
t0 = time.time()
|
||||||
cmd = [
|
cmd = [
|
||||||
str(msbuild), str(VCXPROJ),
|
str(msbuild), str(VCXPROJ),
|
||||||
f"/p:Configuration={config}",
|
f"/p:Configuration={msbuild_config}",
|
||||||
f"/p:Platform={platform}",
|
f"/p:Platform={platform}",
|
||||||
f"/m:{jobs}",
|
f"/m:{jobs}",
|
||||||
"/nologo",
|
"/nologo",
|
||||||
"/v:" + ("normal" if verbose else "minimal"),
|
"/v:" + ("normal" if verbose else "minimal"),
|
||||||
]
|
]
|
||||||
print(f"[build] {config} | {platform}")
|
if rebuild:
|
||||||
return subprocess.run(cmd).returncode == 0
|
cmd.append("/t:Rebuild")
|
||||||
|
result = subprocess.run(cmd, capture_output=not verbose)
|
||||||
|
return result.returncode == 0, time.time() - t0
|
||||||
|
|
||||||
|
|
||||||
def collect_dll(config: str, platform: str, dist_subdir: str) -> Path | None:
|
def collect_dll(msbuild_config: str, platform: str, dist_subdir: str) -> Path | None:
|
||||||
"""Trova la DLL prodotta dal vcxproj e la copia in dist/<dist_subdir>/."""
|
"""Find built DLL and copy to dist/<dist_subdir>/. Returns dest path or None."""
|
||||||
plat_dir = "" if platform == "Win32" else platform + "/"
|
plat_dir = "" if platform == "Win32" else platform + "/"
|
||||||
candidates = list(SRC_DIR.glob(f"**/{plat_dir}{config}/{DLL_NAME}"))
|
candidates = list(SRC_DIR.glob(f"**/{plat_dir}{msbuild_config}/{DLL_NAME}"))
|
||||||
candidates += list(ROOT.glob(f"**/{plat_dir}{config}/{DLL_NAME}"))
|
candidates += list(ROOT.glob(f"**/{plat_dir}{msbuild_config}/{DLL_NAME}"))
|
||||||
if not candidates:
|
if not candidates:
|
||||||
return None
|
return None
|
||||||
src = candidates[0]
|
src = candidates[0]
|
||||||
@@ -100,14 +257,53 @@ def collect_dll(config: str, platform: str, dist_subdir: str) -> Path | None:
|
|||||||
return dst
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def copy_to_plugins(dist_subdir: str, plugins_dir: Path) -> Path | None:
|
||||||
|
"""Copy DLL from dist/<dist_subdir>/ to plugins/<dist_subdir>/."""
|
||||||
|
src = DIST_DIR / dist_subdir / DLL_NAME
|
||||||
|
if not src.exists():
|
||||||
|
return None
|
||||||
|
dst_dir = plugins_dir / dist_subdir
|
||||||
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dst = dst_dir / DLL_NAME
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def build_parallel(msbuild: Path, configs_to_build: list[str],
|
||||||
|
jobs: int, verbose: bool, rebuild: bool) -> list[tuple[str, bool, float]]:
|
||||||
|
"""Build all configs in parallel using ThreadPoolExecutor."""
|
||||||
|
results: list[tuple[str, bool, float]] = []
|
||||||
|
|
||||||
|
def _run(cfg_name: str) -> tuple[str, bool, float]:
|
||||||
|
msbuild_config, platform, _ = CONFIGS[cfg_name]
|
||||||
|
ok, elapsed = build_one(msbuild, msbuild_config, platform, jobs, verbose, rebuild)
|
||||||
|
with _print_lock:
|
||||||
|
icon = f"{Colors.BRIGHT_GREEN}\u2713{Colors.RESET}" if ok else f"{Colors.RED}\u2717{Colors.RESET}"
|
||||||
|
print(f" {icon} {cfg_name:<18} {format_time(elapsed)}")
|
||||||
|
return cfg_name, ok, elapsed
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=len(configs_to_build)) as executor:
|
||||||
|
futures = {executor.submit(_run, cfg): cfg for cfg in configs_to_build}
|
||||||
|
with Spinner(f"Building {len(configs_to_build)} config(s) in parallel..."):
|
||||||
|
for future in as_completed(futures):
|
||||||
|
results.append(future.result())
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description=f"Build ShellExecAsUser plugin")
|
parser = argparse.ArgumentParser(description="Build ShellExecAsUser plugin")
|
||||||
parser.add_argument("--config", default="all",
|
parser.add_argument("--config", default="all",
|
||||||
choices=list(CONFIGS.keys()) + ["all"])
|
choices=list(CONFIGS.keys()) + ["all"])
|
||||||
parser.add_argument("--toolset", default="auto", choices=["2022", "2026", "auto"])
|
parser.add_argument("--toolset", default="auto", choices=["2022", "2026", "auto"])
|
||||||
parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4)
|
parser.add_argument("--jobs", type=int, default=os.cpu_count() or 4)
|
||||||
parser.add_argument("--clean", action="store_true")
|
parser.add_argument("--rebuild", action="store_true", default=True)
|
||||||
parser.add_argument("--install-dir", type=Path, default=None)
|
parser.add_argument("--no-rebuild", action="store_false", dest="rebuild")
|
||||||
|
parser.add_argument("--clean", action="store_true", default=True)
|
||||||
|
parser.add_argument("--no-clean", action="store_false", dest="clean")
|
||||||
|
parser.add_argument("--parallel", action="store_true", default=True)
|
||||||
|
parser.add_argument("--no-parallel", action="store_false", dest="parallel")
|
||||||
|
parser.add_argument("--final", action="store_true", default=False,
|
||||||
|
help="Force rebuild+clean, disable incremental build")
|
||||||
parser.add_argument("--verbose", action="store_true")
|
parser.add_argument("--verbose", action="store_true")
|
||||||
parser.add_argument("--version", action="store_true",
|
parser.add_argument("--version", action="store_true",
|
||||||
help="Mostra versione e termina")
|
help="Mostra versione e termina")
|
||||||
@@ -117,16 +313,76 @@ def main() -> int:
|
|||||||
print(read_version())
|
print(read_version())
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
if args.final:
|
||||||
|
args.rebuild = True
|
||||||
|
args.clean = True
|
||||||
|
|
||||||
if not VCXPROJ.exists():
|
if not VCXPROJ.exists():
|
||||||
print(f"ERROR: {VCXPROJ} not found", file=sys.stderr)
|
print(f"{Colors.RED}ERROR: {VCXPROJ} not found{Colors.RESET}", file=sys.stderr)
|
||||||
return 3
|
return 2
|
||||||
|
|
||||||
if args.clean and DIST_DIR.exists():
|
ver = read_version()
|
||||||
shutil.rmtree(DIST_DIR)
|
print(f"{Colors.BOLD}{Colors.BRIGHT_CYAN}=== Building ShellExecAsUser v{ver} ==={Colors.RESET}")
|
||||||
|
|
||||||
msbuild = find_msbuild(args.toolset)
|
result = find_msbuild(args.toolset)
|
||||||
print(f"[info] MSBuild: {msbuild}")
|
if result is None:
|
||||||
print(f"[info] Version: {read_version()}")
|
print(f"{Colors.RED}ERROR: MSBuild not found{Colors.RESET}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
msbuild, toolset, vs_year = result
|
||||||
|
print(f"{Colors.GRAY}[info]{Colors.RESET} MSBuild: {msbuild}")
|
||||||
|
print(f"{Colors.GRAY}[info]{Colors.RESET} Toolset: {toolset} (VS {vs_year})")
|
||||||
|
print(f"{Colors.GRAY}[info]{Colors.RESET} CPUs: {args.jobs}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
if args.clean:
|
||||||
|
clean_build()
|
||||||
|
|
||||||
|
configs_to_build = list(CONFIGS.keys()) if args.config == "all" else [args.config]
|
||||||
|
wall_t0 = time.time()
|
||||||
|
|
||||||
|
if args.parallel and len(configs_to_build) > 1:
|
||||||
|
build_results = build_parallel(msbuild, configs_to_build, args.jobs, args.verbose, args.rebuild)
|
||||||
|
else:
|
||||||
|
build_results = []
|
||||||
|
for cfg_name in configs_to_build:
|
||||||
|
msbuild_config, platform, _ = CONFIGS[cfg_name]
|
||||||
|
with Spinner(f"Building {cfg_name}..."):
|
||||||
|
ok, elapsed = build_one(msbuild, msbuild_config, platform,
|
||||||
|
args.jobs, args.verbose, args.rebuild)
|
||||||
|
icon = f"{Colors.BRIGHT_GREEN}\u2713{Colors.RESET}" if ok else f"{Colors.RED}\u2717{Colors.RESET}"
|
||||||
|
print(f" {icon} {cfg_name:<18} {format_time(elapsed)}")
|
||||||
|
build_results.append((cfg_name, ok, elapsed))
|
||||||
|
|
||||||
|
wall_elapsed = time.time() - wall_t0
|
||||||
|
print(f"\nAll {len(configs_to_build)} config(s) finished in {format_time(wall_elapsed)} (wall clock)")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
plugins_dir = get_plugins_dir()
|
||||||
|
all_ok = True
|
||||||
|
plugins_copied: list[str] = []
|
||||||
|
for cfg_name, ok, _ in build_results:
|
||||||
|
msbuild_config, platform, dist_subdir = CONFIGS[cfg_name]
|
||||||
|
if ok:
|
||||||
|
dst = collect_dll(msbuild_config, platform, dist_subdir)
|
||||||
|
if dst:
|
||||||
|
print(f" {Colors.GRAY}-> dist/{dist_subdir}/{DLL_NAME}{Colors.RESET}")
|
||||||
|
if plugins_dir:
|
||||||
|
copy_to_plugins(dist_subdir, plugins_dir)
|
||||||
|
plugins_copied.append(dist_subdir)
|
||||||
|
else:
|
||||||
|
print(f" {Colors.YELLOW}! {cfg_name}: DLL not found after build{Colors.RESET}")
|
||||||
|
all_ok = False
|
||||||
|
else:
|
||||||
|
all_ok = False
|
||||||
|
|
||||||
|
if plugins_copied:
|
||||||
|
print(f" {Colors.GRAY}-> plugins/{{{', '.join(plugins_copied)}}}/{Colors.RESET}")
|
||||||
|
|
||||||
|
return 0 if all_ok else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
||||||
targets = list(CONFIGS.keys()) if args.config == "all" else [args.config]
|
targets = list(CONFIGS.keys()) if args.config == "all" else [args.config]
|
||||||
failed = []
|
failed = []
|
||||||
|
|||||||
Reference in New Issue
Block a user