Ctrl K
ComunidadGuías…

install.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# ═══════════════════════════════════════════════════════════════════
#   ✦  T L A M A T I N I  ✦   —   "one who knows"
#
#   Created by  Angela López Mendoza   ·   @angelahack1
#   Developer · Architect · Creator of Tlamatini
#
#   Every line of this file was written by Angela López Mendoza.
# ═══════════════════════════════════════════════════════════════════
#   Tlamatini Author Banner — do not remove (releases scrub the name automatically)
import os
import sys
import json
import ctypes
from ctypes import wintypes
import subprocess
import threading
from setup_wizard import WizardView
from setup_recovery import Recovery, setup_lock
from setup_windows import run_powershell
from uninstall_processes import owned_processes
import tkinter as tk
from tkinter import filedialog, messagebox
import zipfile

from build_runtime_assets import verify_package

PRESERVED_AGENTS_MARKER = "agents/.tlamatini-preserved-agents.json"


# ─── Version resolution ───────────────────────────────────────────────────────
# Read the version from the running .exe's Win32 ProductVersion (frozen mode)
# so the GUI always matches the value PyInstaller's --version-file baked in
# — i.e. what Right-click → Properties → Details → ProductVersion shows.
# In source mode, derive from git tags (same precedence as
# Tlamatini/agent/version.py).  Empty string is a valid return value; the UI
# degrades gracefully when no version is available.

def _read_exe_product_version(exe_path: str) -> str:
    """Read the Win32 ``ProductVersion`` string from an EXE's VERSIONINFO."""
    if sys.platform != "win32":
        return ""
    try:
        ver = ctypes.windll.version
        get_size = ver.GetFileVersionInfoSizeW
        get_size.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)]
        get_size.restype = wintypes.DWORD

        get_info = ver.GetFileVersionInfoW
        get_info.argtypes = [wintypes.LPCWSTR, wintypes.DWORD,
                             wintypes.DWORD, ctypes.c_void_p]
        get_info.restype = wintypes.BOOL

        query = ver.VerQueryValueW
        query.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR,
                          ctypes.POINTER(ctypes.c_void_p),
                          ctypes.POINTER(wintypes.UINT)]
        query.restype = wintypes.BOOL

        handle = wintypes.DWORD(0)
        size = get_size(exe_path, ctypes.byref(handle))
        if not size:
            return ""

        buf = ctypes.create_string_buffer(size)
        if not get_info(exe_path, 0, size, buf):
            return ""

        # Most-likely StringFileInfo language/codepage IDs, in order of
        # priority.  build.py's render_pyinstaller_version_file() writes
        # ``040904B0`` (en-US, Unicode).  The other entries are safety nets.
        for codepage in ("040904B0", "040904E4", "000004B0"):
            sub = f"\\StringFileInfo\\{codepage}\\ProductVersion"
            value = ctypes.c_void_p(0)
            length = wintypes.UINT(0)
            if query(buf, sub, ctypes.byref(value), ctypes.byref(length)):
                if value.value and length.value > 0:
                    return ctypes.wstring_at(value.value, length.value).rstrip("\x00")
    except Exception:
        return ""
    return ""


def _derive_version_from_git() -> str:
    """Return the most recent reachable ``v*`` tag, stripped of the ``v``."""
    try:
        cwd = os.path.dirname(os.path.abspath(__file__))
        result = subprocess.run(
            ["git", "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"],
            cwd=cwd, capture_output=True, text=True, timeout=5,
        )
    except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
        return ""
    if result.returncode != 0:
        return ""
    tag = (result.stdout or "").strip()
    return tag[1:] if tag.startswith("v") else tag


def resolve_version() -> str:
    """Resolve the running installer's version (frozen→EXE, source→git)."""
    if getattr(sys, "frozen", False):
        version = _read_exe_product_version(sys.executable)
        if version:
            return version
    derived = _derive_version_from_git()
    if derived:
        return derived
    return ""


# ─── DLL-locking prevention (PyInstaller --onedir) ────────────────────────────
# When the installer runs as a frozen exe, vcruntime140.dll and
# vcruntime140_1.dll live inside _internal/.  Any child process that
# *inherits* DLL handles — or loads them from the DLL search path —
# will keep those files locked even after the installer exits.
# The three mitigations below prevent that.

def _reset_dll_search_path():
    """Remove the PyInstaller _internal/ dir from the Windows DLL search order.

    Calling SetDllDirectoryW(NULL) tells the loader to stop searching the
    app directory for DLLs, falling back to the standard search sequence
    (System32, Windows, PATH).  This prevents child processes from picking
    up vcruntime140*.dll from _internal/ via DLL search-order inheritance.
    """
    if sys.platform == "win32":
        try:
            ctypes.windll.kernel32.SetDllDirectoryW(None)
        except Exception:
            pass  # non-fatal; best effort


def _free_vc_runtime_handles():
    """Explicitly unload bundled VC runtime DLLs so our reference count drops.

    This is a belt-and-suspenders complement to the search-path fix above.
    If any module in-process has bumped the DLL refcount (e.g. Tkinter or
    subprocess internals), releasing it here helps Windows truly unlock the
    file at installer-exit time.
    """
    if sys.platform != "win32":
        return
    k32 = ctypes.windll.kernel32
    GetModuleHandleW = k32.GetModuleHandleW
    GetModuleHandleW.restype = ctypes.c_void_p
    FreeLibrary = k32.FreeLibrary
    for dll_name in ("vcruntime140.dll", "vcruntime140_1.dll"):
        try:
            handle = GetModuleHandleW(dll_name)
            if handle:
                FreeLibrary(handle)
        except Exception:
            pass  # non-fatal


# ─── Color Palette ────────────────────────────────────────────────────────────
BG_DARK       = "#0f0f1a"
BG_PANEL      = "#1a1a2e"
BG_CARD       = "#16213e"
BG_INPUT      = "#0f3460"
FG_PRIMARY    = "#e0e0ff"
FG_SECONDARY  = "#8888aa"
FG_DIM        = "#555577"
ACCENT        = "#00d4ff"
ACCENT_HOVER  = "#00f0ff"
ACCENT_GLOW   = "#0099cc"
SUCCESS       = "#00e676"
WARNING       = "#ffab40"
ERROR         = "#ff5252"
BTN_BG        = "#0f3460"
BTN_HOVER     = "#1a4a8a"
BTN_CANCEL_BG = "#2a1a2e"
BTN_CANCEL_HV = "#3d2244"
PROGRESS_BG   = "#1a1a2e"
PROGRESS_FG   = "#00d4ff"
BORDER_COLOR  = "#2a2a4e"

FONT_FAMILY   = "Segoe UI"


class FancyInstaller:
    """Modern dark-themed installer for Tlamatini."""

    # ── weighted installation steps ──────────────────────────────────
    STEPS = [
        ("Preparing installation directory…",  0.05),
        ("Extracting files…",                  0.60),
        ("Securing agent environments…",       0.05),
        ("Writing configuration…",             0.05),
        ("Copying uninstaller…",               0.05),
        ("Creating shortcuts…",                0.075),
        ("Registering .flw and .fpmt file associations…", 0.075),
        ("Refreshing Windows Desktop…",        0.05),
    ]

    def __init__(self, root: tk.Tk):
        self.root = root
        self.version = resolve_version()
        title = f"Tlamatini Installer v{self.version}" if self.version else "Tlamatini Installer"
        self.root.title(title)
        self.zip_path = self._find_zip()

        self.install_path = tk.StringVar()
        self._progress_value = 0.0
        self._installing = False

        self._build_ui()
        if not self.zip_path:
            self._show_error("pkg.zip was not found alongside the installer.\n\n"
                             "Keep the complete release folder together, then retry.")

    # ─── Resource helper ──────────────────────────────────────────────
    @staticmethod
    def _find_zip() -> str | None:
        """Find pkg.zip sitting exactly next to the Installer.exe"""
        if getattr(sys, 'frozen', False):
            base = os.path.dirname(sys.executable)
        else:
            base = os.path.abspath(os.path.dirname(__file__))
            
        p = os.path.join(base, "pkg.zip")
        return p if os.path.isfile(p) else None

    # ─── UI Construction ──────────────────────────────────────────────
    def _build_ui(self):
        self.ui = WizardView(
            self.root, removal=False, version=self.version,
            path=self.install_path, steps=self.STEPS,
            start=self._start_install, browse=self._browse,
        )
        # Keep the worker and existing integrations on their public UI handles.
        self.path_entry = self.ui.path_entry
        self.browse_btn = self.ui.browse_btn
        self.install_btn = self.ui.primary_btn
        self.cancel_btn = self.ui.cancel_btn
        self.button_row = self.ui.button_row
        self.progress_frame = self.ui.progress_frame
        self.step_label = self.ui.step_label
        self.progress_bar = self.ui.progress_bar
        self.pct_label = self.ui.pct_label
        self.check_labels = self.ui.check_labels
        self.target_label = self.ui.target_label

    # ─── Path helpers ─────────────────────────────────────────────────
    def _on_path_change(self, *_):
        raw = self.install_path.get().strip()
        if raw:
            full = os.path.join(raw, "Tlamatini")
            self.target_label.config(text=f"➜  {full}")
        else:
            self.target_label.config(text="")

    def _browse(self):
        path = filedialog.askdirectory(title="Choose a parent directory for Tlamatini")
        if path:
            self.install_path.set(path)

    # ─── Validation ───────────────────────────────────────────────────
    def _validate_path(self) -> str | None:
        """Return the full install dir or None on failure."""
        raw = self.install_path.get().strip()
        if not raw:
            messagebox.showwarning("No path selected",
                                   "Please select an installation directory.")
            return None

        # Parent must exist
        if not os.path.isdir(raw):
            messagebox.showerror(
                "Invalid path",
                f"The directory does not exist:\n{raw}\n\n"
                "Please select an existing directory.",
            )
            return None

        target = os.path.join(raw, "Tlamatini")

        target = os.path.abspath(target)
        if (os.path.normcase(os.path.realpath(target)) != os.path.normcase(target)
                or os.path.exists(os.path.join(target, ".git"))
                or os.path.isfile(os.path.join(target, "Tlamatini", "manage.py"))
                or os.path.isfile(target)):
            messagebox.showerror("Invalid installation folder",
                                 "Choose a separate application folder. Setup cannot overwrite a source checkout, a file or a redirected directory.")
            return None

        # Warn if target is not empty
        if os.path.isdir(target) and os.listdir(target):
            ans = messagebox.askyesno(
                "Directory not empty",
                f"The target directory already contains files:\n{target}\n\n"
                "Do you want to choose a different directory?\n\n"
                "Click Yes to choose another directory.\n"
                "Click No to continue and overwrite.",
            )
            if ans:          # user chose "Yes" → pick another
                self._browse()
                return None  # abort this attempt (user can click Install again)

        return target

    # ─── Enter/Return key ─────────────────────────────────────────────
    def _on_enter_key(self, event=None):
        return self.ui._enter(event)

    # ─── Installation thread ──────────────────────────────────────────
    def _start_install(self):
        if self._installing or self.ui.state == "complete":
            return

        self.zip_path = self._find_zip()
        if not self.zip_path:
            self._show_error("pkg.zip was not found alongside the installer.\n\n"
                             "Keep the complete release folder together, then retry.")
            return

        target = self._validate_path()
        if target is None:
            return

        self._installing = True
        self.ui.begin(target)

        t = threading.Thread(target=self._run_install, args=(target,), daemon=True)
        t.start()

    # ── Progress helpers (always marshal to main thread) ──────────────
    def _set_progress(self, value: float, status: str | None = None):
        self._progress_value = value
        self.root.after(0, self._update_progress_ui, value, status)

    def _update_progress_ui(self, value: float, status: str | None):
        self.ui.update_progress(value, status)

    def _mark_step(self, idx: int, success: bool = True):
        self.root.after(0, self.ui.mark_step, idx, success)

    def _activate_step(self, idx: int):
        self.root.after(0, self.ui.activate_step, idx)

    # ─── Main install pipeline (runs in background thread) ────────────
    # ── User state that must survive a reinstall ──────────────────────────
    # The list lives in ONE place, `preserved_user_state.json`, which
    # apply_update.ps1 and uninstall.py answer to as well. Three private
    # copies is how they drifted: the updater preserved external_mcps.json
    # while the installer preserved nothing.
    _PRESERVE_FALLBACK = (
        "config.json", "external_mcps.json", "contacts.json", "data.keys", "DB",
        "application", "applications", "content_generated", "Temp",
        "context_files", "doc_generated", "documentation", "Templates",
        "Uninstaller.exe",
    )

    @staticmethod
    def _preserved_names() -> set:
        """Read the shared list; fall back to the built-in copy.

        FAIL-SAFE, not fail-open: if the file is missing or unreadable we
        preserve the built-in set anyway. Erring towards keeping the user's
        data is the only acceptable direction — the alternative is deleting
        their API keys because a JSON file did not parse.
        """
        here = os.path.dirname(os.path.abspath(__file__))
        for candidate in (os.path.join(here, "preserved_user_state.json"),
                          os.path.join(here, "_internal",
                                       "preserved_user_state.json")):
            try:
                with open(candidate, "r", encoding="utf-8-sig") as fh:
                    doc = json.load(fh)
                names = doc.get("preserve")
                if isinstance(names, list) and names:
                    return {str(n).strip().lower() for n in names if str(n).strip()}
            except Exception:
                continue
        return {n.lower() for n in FancyInstaller._PRESERVE_FALLBACK}

    @staticmethod
    def _is_preserved_member(member: str, target: str, preserved: set) -> bool:
        """True when this zip member belongs to EXISTING user state.

        Matching is on the TOP-LEVEL name, so `DB/db.sqlite3` is protected by
        the entry `DB`. Nothing is skipped unless it is already on disk — a
        fresh install must still receive its seed files.
        """
        normalized = member.replace("\\", "/").strip("/")
        if not normalized:
            return False
        # The live DB is nested in application code, not the top-level DB/
        # export/import directory. Never replace it (or its WAL companions)
        # with the release's seeded database during an in-place reinstall.
        if normalized.lower() in {"_internal/db.sqlite3", "_internal/db.sqlite3-wal",
                                  "_internal/db.sqlite3-shm", "_internal/db.sqlite3-journal"}:
            return os.path.isfile(os.path.join(target, "_internal", "db.sqlite3"))
        top = normalized.split("/")[0]
        if top.lower() not in preserved:
            return False
        return os.path.exists(os.path.join(target, top))

    def _run_install(self, target: str):
        recovery = None
        self.setup_warnings = []
        try:
            with setup_lock():
                self._set_progress(0, "Checking release integrity and recovery…")
                try:
                    document = verify_package(self.zip_path)
                except Exception as exc:
                    raise RuntimeError(
                        "The release package could not be verified. No installation files were changed.\n\n"
                        "Extract the complete release again, keeping pkg.zip beside Installer.exe, then retry. "
                        "If Windows denied access, close applications holding the package and check its permissions.\n\n"
                        f"Details: {exc}"
                    ) from exc
                if owned_processes(target):
                    raise RuntimeError("Close Tlamatini and its remaining agents before installing into this folder, then retry.")
                recovery = Recovery(target, lambda message: self._set_progress(0, message))
                recovery.recover_previous()
                self._existing_preserved = {name for name in self._preserved_names()
                                            if os.path.exists(os.path.join(target, name))}
                names = [name for name in document["files"] if not self._is_preserved_member(
                    name, target, self._existing_preserved)]
                names += ["runtime-assets.json", "CreateShortcut.json", "Uninstaller.exe", "Tlamatini.lnk", "CreateShortcut_Temp.vbs"]
                # Reinstall clears the uninstall-only marker. Include it in
                # recovery so a later failure restores the preserved catalog.
                names.append(PRESERVED_AGENTS_MARKER)
                # The existing environment patcher can edit older retained code too.
                for parent, _dirs, files in os.walk(os.path.join(target, "Tlamatini")):
                    names += [os.path.relpath(os.path.join(parent, name), target).replace("\\", "/")
                              for name in files if name.endswith(".py")]
                required = sum(record["bytes"] for record in document["files"].values())
                with zipfile.ZipFile(self.zip_path, 'r') as package:
                    directories = [entry.filename.rstrip('/') for entry in package.infolist()
                                   if entry.is_dir()]
                recovery.prepare(names, required_space=required, created_directories=directories)
                try:
                    self._run_install_steps(target)
                except Exception as exc:
                    # Preserve the original cause before a potentially lengthy
                    # rollback; the visible wizard continues to report recovery.
                    import traceback
                    traceback.print_exc()
                    self._set_progress(0, "Restoring the previous installation…")
                    try:
                        recovery.rollback()
                    except Exception as rollback_error:
                        raise RuntimeError(f"{exc}\n\n{rollback_error}") from exc
                    raise RuntimeError(f"{exc}\n\nThe previous files and Windows registrations were restored. You can retry.") from exc
                self.setup_notice = "\n".join(filter(None, [recovery.commit(), *self.setup_warnings]))
            self.root.after(0, self._show_success, target)
        except Exception as exc:
            self.root.after(0, self._show_error, str(exc))

    def _run_install_steps(self, target: str):
        self._set_progress(0.0, "Verifying release integrity before installation…")
        existing_db = os.path.join(target, "_internal", "db.sqlite3")
        if os.path.isfile(existing_db):
            # Keep the DB/WAL in place; next startup applies new migrations.
            # Set before extraction so even an interrupted reinstall retries.
            state_dir = os.path.join(target, "DB")
            os.makedirs(state_dir, exist_ok=True)
            with open(os.path.join(state_dir, "post_update_migrate.flag"), "w", encoding="utf-8") as flag:
                flag.write("in-place reinstall: retained existing database and WAL\n")
        cumulative = 0.0  # tracks completed-step weight

        # ── Step 0: create directory ─────────────────────────────
        step_idx = 0
        self._activate_step(step_idx)
        self._set_progress(0.0, "Creating installation directory…")
        os.makedirs(target, exist_ok=True)
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 1: extract pkg.zip ──────────────────────────────
        #
        # PRESERVING USER STATE (Angela, 2026-08-12). This used to extract
        # EVERY member unconditionally, so installing over an existing
        # Tlamatini overwrote `config.json` (every provider key and
        # setting), `external_mcps.json` (the whole External-MCP catalog
        # AND its active set), `contacts.json` and the DB. That is why
        # "every time I reinstall, all the external MCPs go fucked" — the
        # self-updater had a $Preserve list and the installer had none.
        #
        # Now the same list guards both. A preserved item is SKIPPED only
        # when it already exists, so a FRESH install still gets its seed
        # files; only an upgrade-in-place leaves the user's copy alone.
        preserved = self._existing_preserved
        # Capture existence ONCE. Otherwise extracting the first seed file
        # makes its new directory look pre-existing and skips sibling seeds.
        preserved = {name for name in preserved if os.path.exists(os.path.join(target, name))}
        kept: list[str] = []
        step_idx = 1
        self._activate_step(step_idx)
        weight = self.STEPS[step_idx][1]
        with zipfile.ZipFile(self.zip_path, "r") as zf:
            members = zf.namelist()
            total = len(members)
            for i, member in enumerate(members, 1):
                if self._is_preserved_member(member, target, preserved):
                    top = member.replace("\\", "/").split("/")[0]
                    if top not in kept:
                        kept.append(top)
                else:
                    zf.extract(member, target)
                frac = i / total
                self._set_progress(
                    cumulative + weight * frac,
                    f"Extracting files… ({i}/{total})",
                )
        if kept:
            print(f"[INSTALL] Preserved existing user state: {', '.join(sorted(kept))}")
        self._clear_preserved_agents_marker(target)
        cumulative += weight
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 2: Secure agent environments (PyInstaller fix) ──────
        step_idx = 2
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Securing agent environments…")
        self._patch_agent_environments(os.path.join(target, "Tlamatini"))
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 3: write CreateShortcut.json ────────────────────
        step_idx = 3
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Writing configuration…")
        config_data = {"InstallDir": target.replace("/", "\\")}
        config_path = os.path.join(target, "CreateShortcut.json")
        with open(config_path, "w", encoding="utf-8") as f:
            json.dump(config_data, f, indent=4)
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 4: copy Uninstaller.exe + register in "Installed apps" ──
        step_idx = 4
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Copying uninstaller…")
        self._copy_uninstaller(target)
        # Now that Uninstaller.exe is in place, advertise Tlamatini in the
        # Windows Settings ▸ Apps ▸ Installed apps (and legacy Programs and
        # Features) list with a working Uninstall button. Per-user (HKCU),
        # non-fatal: a registry hiccup must not fail the whole install.
        self._set_progress(cumulative, "Registering with Windows…")
        self._register_programs_entry(target)
        # Companion-app discovery is registered INDEPENDENTLY of the ARP entry
        # above (REQ-S2-INSTALL-001/002/003): a valid installed agents catalog
        # must be discoverable by FlowPills even when Uninstaller.exe is missing
        # or the ARP registration raised. Own fail-open boundary inside.
        self._register_companion_discovery(target)
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 5: run CreateShortcut.ps1 ───────────────────────
        step_idx = 5
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Creating shortcuts…")
        self._run_ps1("CreateShortcut.ps1", target)
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 6: register both flow formats ─────────────────────────
        step_idx = 6
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Registering .flw and .fpmt file associations…")
        self._run_ps1("register_flw.ps1", target)
        self._run_ps1("register_fpmt.ps1", target)
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(cumulative)
        self._mark_step(step_idx)

        # ── Step 7: Restart Explorer ──────────────────────────────
        step_idx = 7
        self._activate_step(step_idx)
        self._set_progress(cumulative, "Refreshing Windows Desktop…")
        self._restart_explorer()
        cumulative += self.STEPS[step_idx][1]
        self._set_progress(1.0, "Installation complete!")
        self._mark_step(step_idx)

        # ── Done ─────────────────────────────────────────────────

    def _clear_preserved_agents_marker(self, target: str):
        """Stop advertising a restored installation as an uninstalled catalog."""
        marker = os.path.join(target, PRESERVED_AGENTS_MARKER)
        try:
            os.unlink(marker)
        except FileNotFoundError:
            pass
        except OSError as exc:
            # Discovery metadata is fail-open, just like registry publication.
            warning = f"The old preserved-agents marker could not be removed: {marker}. {exc}"
            self.setup_warnings.append(warning)
            print(f"[INSTALL] {warning}")


    @staticmethod
    def _get_clean_env():
        """Retrieve a clean environment without PyInstaller's DLL paths to prevent locking."""
        clean_env = os.environ.copy()
        if getattr(sys, 'frozen', False):
            # Remove _MEIPASS and the exe directory from PATH so child
            # processes never discover vcruntime140*.dll inside _internal/.
            meipass = (sys._MEIPASS if hasattr(sys, '_MEIPASS') else "").lower()
            exe_dir = os.path.dirname(sys.executable).lower()
            internal_dir = os.path.join(exe_dir, "_internal").lower()
            blocked = {meipass, exe_dir, internal_dir}
            paths = clean_env.get("PATH", "").split(os.pathsep)
            paths = [
                p for p in paths
                if p.lower() not in blocked
                and not any(p.lower().startswith(b + os.sep) for b in blocked if b)
            ]
            clean_env["PATH"] = os.pathsep.join(paths)
        return clean_env

    # ─── PS1 helper ───────────────────────────────────────────────────
    def _run_ps1(self, filename: str, target_dir: str):
        dst = os.path.join(target_dir, filename)
        if not os.path.isfile(dst):
            raise FileNotFoundError(f"{filename} not found at {dst}")
        return run_powershell(dst, env=self._get_clean_env())

    # ─── Uninstaller copy helper ─────────────────────────────────────
    @staticmethod
    def _copy_uninstaller(target_dir: str):
        """Copy Uninstaller.exe from next to the installer into the install dir."""
        import shutil
        if getattr(sys, 'frozen', False):
            base = os.path.dirname(sys.executable)
        else:
            base = os.path.abspath(os.path.dirname(__file__))

        src = os.path.join(base, "Uninstaller.exe")
        if os.path.isfile(src):
            dst = os.path.join(target_dir, "Uninstaller.exe")
            shutil.copy2(src, dst)
        else:
            raise FileNotFoundError(
                f"Uninstaller.exe is missing from the release folder: {src}. "
                "Restore the complete release folder and retry."
            )

    # ─── "Installed apps" (Add/Remove Programs) registration ─────────
    def _register_programs_entry(self, target_dir: str):
        """Write the per-user ARP entry so Tlamatini appears in Windows'
        Settings ▸ Apps ▸ Installed apps (and legacy Programs and Features),
        with an Uninstall button that launches the bundled Uninstaller.exe.

        Self-contained (the installer is a standalone frozen exe and cannot
        import agent.*). HKCU only — matches the per-user, non-elevated install.
        Best-effort: never raises into the install pipeline.
        """
        if sys.platform != "win32":
            return
        try:
            import winreg

            install_dir = os.path.abspath(target_dir)
            uninstaller = os.path.join(install_dir, "Uninstaller.exe")
            if not os.path.isfile(uninstaller):
                print("WARNING: Uninstaller.exe missing — skipping Installed-apps registration.")
                return

            icon = os.path.join(install_dir, "Tlamatini.ico")
            exe = os.path.join(install_dir, "Tlamatini.exe")
            display_icon = icon if os.path.isfile(icon) else (
                f"{exe},0" if os.path.isfile(exe) else uninstaller
            )
            quoted_uninstaller = f'"{uninstaller}"'

            # EstimatedSize (KB) for the "Size" column — best-effort, capped.
            size_kb = 0
            try:
                seen = 0
                total = 0
                for root, _dirs, files in os.walk(install_dir):
                    for name in files:
                        seen += 1
                        if seen > 60000:
                            raise StopIteration
                        try:
                            total += os.path.getsize(os.path.join(root, name))
                        except OSError:
                            pass
                size_kb = total // 1024
            except StopIteration:
                size_kb = total // 1024
            except Exception:
                size_kb = 0

            key_path = r"Software\Microsoft\Windows\CurrentVersion\Uninstall\Tlamatini"
            with winreg.CreateKey(winreg.HKEY_CURRENT_USER, key_path) as key:
                winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "Tlamatini")
                if self.version:
                    winreg.SetValueEx(key, "DisplayVersion", 0, winreg.REG_SZ, self.version)
                winreg.SetValueEx(key, "Publisher", 0, winreg.REG_SZ, "XAIHT")
                winreg.SetValueEx(key, "DisplayIcon", 0, winreg.REG_SZ, display_icon)
                winreg.SetValueEx(key, "InstallLocation", 0, winreg.REG_SZ, install_dir)
                winreg.SetValueEx(key, "UninstallString", 0, winreg.REG_SZ, quoted_uninstaller)
                # There is no silent-uninstall mode; do not advertise one.
                try:
                    winreg.DeleteValue(key, "QuietUninstallString")
                except FileNotFoundError:
                    pass
                winreg.SetValueEx(key, "URLInfoAbout", 0, winreg.REG_SZ,
                                  "https://github.com/XAIHT/Tlamatini")
                winreg.SetValueEx(key, "NoModify", 0, winreg.REG_DWORD, 1)
                winreg.SetValueEx(key, "NoRepair", 0, winreg.REG_DWORD, 1)
                if size_kb > 0:
                    winreg.SetValueEx(key, "EstimatedSize", 0, winreg.REG_DWORD, size_kb)
            print(f"Registered Tlamatini in Installed apps (HKCU): {key_path}")
        except Exception as e:
            print(f"WARNING: Could not register Installed-apps entry: {e}")
            self.setup_warnings = [*getattr(self, "setup_warnings", []),
                                   f"Windows Installed apps registration needs attention: {e}"]

    def _register_companion_discovery(self, target_dir: str):
        """Write the HKCU companion-app discovery key (Software\\XAIHT\\Tlamatini) so
        XAIHT companion apps (Tlamatini-FlowPills) can find the agents root without
        importing Python or scanning drives.

        INDEPENDENT of the Installed-apps/ARP registration and of Uninstaller.exe
        (REQ-S2-INSTALL-001/002/003): an install with a valid agents catalog must be
        discoverable by FlowPills even when ARP registration is skipped or fails.
        Self-contained (the installer is a standalone frozen exe and cannot import
        agent.*). HKCU only, fail-open — never raises into the install pipeline.
        """
        if sys.platform != "win32":
            return
        try:
            import json
            import winreg

            install_dir = os.path.abspath(target_dir)
            agents_root = os.path.join(install_dir, "agents")
            manifest_path = os.path.join(agents_root, "_tlamatini_agents_manifest.json")
            catalog_version = ""
            try:
                if os.path.isfile(manifest_path):
                    # utf-8-sig tolerates a BOM if one was written.
                    with open(manifest_path, encoding="utf-8-sig") as mf:
                        catalog_version = str(json.load(mf).get("agent_catalog_version", "") or "")
            except Exception:
                catalog_version = ""

            disc_key = r"Software\XAIHT\Tlamatini"
            # REQ-S2-INSTALL-004 / REQ-S2-REG-001: write ALL SIX values, empty string
            # when unknown, so no stale value from a previous install survives.
            with winreg.CreateKey(winreg.HKEY_CURRENT_USER, disc_key) as dkey:
                winreg.SetValueEx(dkey, "InstallLocation", 0, winreg.REG_SZ, install_dir)
                winreg.SetValueEx(dkey, "AgentsRoot", 0, winreg.REG_SZ, agents_root)
                winreg.SetValueEx(dkey, "SourceAgentsRoot", 0, winreg.REG_SZ, "")
                winreg.SetValueEx(
                    dkey, "AgentManifestPath", 0, winreg.REG_SZ,
                    manifest_path if os.path.isfile(manifest_path) else "",
                )
                winreg.SetValueEx(dkey, "Version", 0, winreg.REG_SZ, self.version or "")
                winreg.SetValueEx(dkey, "AgentCatalogVersion", 0, winreg.REG_SZ, catalog_version)
            print(f"Registered companion-app discovery (HKCU): {disc_key}")
        except Exception as de:
            print(f"WARNING: Could not register companion-app discovery: {de}")
            self.setup_warnings = [*getattr(self, "setup_warnings", []),
                                   f"Companion app discovery needs attention: {de}"]

    # ─── Desktop refresh helper ──────────────────────────────────────
    @staticmethod
    def _restart_explorer():
        """Invalidate associations without deleting caches or waiting on every app."""
        try:
            ctypes.windll.shell32.SHChangeNotify(0x08000000, 0, None, None)
        except OSError as exc:
            print(f"WARNING: Desktop refresh failed: {exc}")

    # ─── Environment Patching helper ──────────────────────────────────
    def _patch_agent_environments(self, tlamatini_dir: str):
        """
        Scans all Python files injected by checking for agent definitions or 
        views.py to inject PyInstaller DLL resolution workarounds dynamically.
        """
        anchor = "    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):"
        patch = (
            "    # Reset PyInstaller's DLL search path alteration on Windows\n"
            "    # If we don't do this, child Python processes will WinError 1114 when loading C extensions (like torch)\n"
            "    if sys.platform.startswith('win'):\n"
            "        try:\n"
            "            import ctypes\n"
            "            if hasattr(ctypes.windll.kernel32, 'SetDllDirectoryW'):\n"
            "                ctypes.windll.kernel32.SetDllDirectoryW(None)\n"
            "        except Exception:\n"
            "            pass\n\n"
            "    # Remove PyInstaller's _MEIPASS from PATH to prevent DLL conflicts in child processes\n"
            "    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):"
        )
        
        try:
            import glob
            # Include main views.py and agents logic
            paths_to_check = glob.glob(os.path.join(tlamatini_dir, "agent", "agents", "*", "*.py"))
            paths_to_check.append(os.path.join(tlamatini_dir, "agent", "views.py"))

            for filepath in paths_to_check:
                if not os.path.exists(filepath):
                    continue
                with open(filepath, 'r', encoding='utf-8') as f:
                    content = f.read()
                
                # Check if anchor is present and it hasn't already been patched
                if anchor in content and "SetDllDirectoryW" not in content:
                    content = content.replace(anchor, patch)
                    with open(filepath, 'w', encoding='utf-8') as f:
                        f.write(content)
        except Exception as e:
            print(f"Non-fatal error patching agent environments: {e}")

    # ─── Completion dialogs ───────────────────────────────────────────
    def _show_success(self, target: str):
        self._installing = False
        self.ui.finish(success=True, target=target, detail=(
            "Tlamatini is installed.\n\nDesktop shortcuts are ready.\n"
            ".flw and .fpmt flow files are now associated with Tlamatini."
            + ("\n\n" + self.setup_notice if getattr(self, "setup_notice", "") else "")
        ))

    def _show_error(self, detail: str):
        self._installing = False
        self.ui.finish(success=False, detail=detail)


# ─── Entry point ──────────────────────────────────────────────────────────────
if __name__ == "__main__":
    from setup_logging import configure
    configure("installation")
    # ── Layer 1: reset DLL search path BEFORE anything else ──────────
    # This prevents child processes from discovering vcruntime140*.dll
    # inside PyInstaller's _internal/ directory.
    _reset_dll_search_path()

    # Tell the PyInstaller bootloader splash that Python is now running.
    # This is a no-op when the script is run directly (not from a bundle).
    try:
        if os.environ.get("_PYI_SPLASH_IPC"):
            import pyi_splash
            pyi_splash.update_text("Loading installer…")
    except Exception:
        pass

    root = tk.Tk()
    root.withdraw()             # hide the window while the UI is being built

    app = FancyInstaller(root)

    root.update_idletasks()     # flush geometry/draw events so the window is
                                # fully rendered before the splash disappears

    # Dismiss the splash and reveal the installer window in one beat so there
    # is no visible gap between the two.
    try:
        if os.environ.get("_PYI_SPLASH_IPC"):
            import pyi_splash
            pyi_splash.close()
    except Exception:
        pass

    try:
        root.deiconify()        # show the fully-built installer window
    except tk.TclError:
        pass                    # window was destroyed during init (e.g. pkg.zip missing)

    root.mainloop()

    # ── Layer 4: release VC runtime DLL handles before exit ──────────
    # This drops our in-process reference count so Windows can truly
    # unlock the files once the process terminates.
    _free_vc_runtime_handles()