#!/usr/bin/env python3
"""
This file is part of YUMI

It is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 2 of the License, or
any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with it. If not, see <https: www.gnu.org licenses/>.
Copyright https://yumiusb.com (See YUMI-Copying.txt for more information, Credits, and Licensing)

YUMI PY - Your Universal Multiboot Integrator
Standalone Python/tkinter desktop app.

Run: python3 yumi_gui.py
Requires: Python 3.9+, tkinter (usually bundled with Python)
Place distros.json in the same folder as this script.

"""

from typing import Dict, List, Optional, Set, Tuple

import fnmatch
import json
import os
import platform
import queue
import re
import shutil
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import urllib.request
import webbrowser
import zipfile
from pathlib import Path
from tkinter import (
    BooleanVar, END, Entry, Frame, IntVar, Label, Listbox, Menu,
    PhotoImage, Scrollbar, StringVar, Text, Tk, Toplevel, filedialog, messagebox,
    ttk,
)
import tkinter as tk

# ── Paths ──────────────────────────────────────────────────────────────────────
# When running as a Nuitka --onefile binary, data files are embedded inside
# the binary and extracted to __nuitka_data_dir__ at startup.
def _get_base_dir() -> Path:
    data_dir = getattr(sys, "__nuitka_data_dir__", None)
    if data_dir:
        return Path(data_dir)
    if getattr(sys, "__compiled__", False):
        return Path(__file__).resolve().parent
    return Path(__file__).resolve().parent

BASE_DIR   = _get_base_dir()
YUMI4LINUX = BASE_DIR / "YUMI4Linux"
# Auto-detect the bundled Ventoy directory - works regardless of version
def _find_ventoy_dir(base: Path) -> Path:
    for candidate in sorted(base.glob("ventoy-*")):
        if candidate.is_dir():
            return candidate
    return base / "ventoy-unknown"  # fallback - will fail gracefully later

VENTOY_DIR = _find_ventoy_dir(YUMI4LINUX)
YUMI2DISK  = VENTOY_DIR / "YUMI2Disk.sh"
YUMIFILES  = BASE_DIR / "YUMI4Linux" / "YUMIFILES"

def _find_windows_ventoy_zip(tools_dir: Path) -> Optional[Path]:
    """Locate the bundled Ventoy-for-Windows zip directly, independent of any
    YUMI4Linux folder/version. YUMI4Linux is Linux-only payload (YUMI2Disk.sh,
    the Linux ventoy-<ver> binaries) and isn't otherwise touched on Windows, so
    tying the Windows zip lookup to a YUMI4Linux\\ventoy-<ver> folder name only
    forced bundling that whole (large) folder into Windows builds just to read
    a version string. Glob the actual file we use instead."""
    matches = sorted(tools_dir.glob("ventoy-*-windows.zip"))
    return matches[0] if matches else None

IS_WINDOWS = platform.system() == "Windows"
IS_LINUX   = platform.system() == "Linux"

def _set_windows_dpi_awareness() -> None:
    """Must run before the first Tk()/messagebox call. Without this,
    Windows treats the process as DPI-unaware and bitmap-stretches the
    whole app to fit the display's scale instead of letting Tk render
    natively at the higher resolution - blurry, and it also throws off
    what `tk scaling` reports later."""
    if not IS_WINDOWS:
        return
    try:
        import ctypes
        ctypes.windll.shcore.SetProcessDpiAwareness(1)  # per-monitor DPI aware
    except Exception:
        try:
            import ctypes
            ctypes.windll.user32.SetProcessDPIAware()  # older Windows fallback
        except Exception:
            pass

# ── Version / update check ───────────────────────────────────────────────────
# Bump APP_VERSION on each release. UPDATE_CHECK_URL should point to a plain
# text file containing just the latest version string (mirrors the
# yumiversion.txt pattern used by YUMI.nsi's own "Checker" function).
APP_VERSION         = "1.0.0"
UPDATE_CHECK_URL    = "https://yumiusb.com/downloads/yumipyversion.txt"
UPDATE_DOWNLOAD_URL = "https://yumiusb.com/yumi-exfat/"
AUTO_CHECK_FOR_UPDATES = False  # silent check at startup; manual check always works regardless

# ── Subprocess helpers (suppress console windows on Windows) ───────────────────
# On Windows, every subprocess.Popen/run/check_output must pass
# creationflags=CREATE_NO_WINDOW or a black cmd/PowerShell window will flash.
_WNOW = subprocess.CREATE_NO_WINDOW if IS_WINDOWS else 0

def _popen(cmd, **kw) -> subprocess.Popen:
    """subprocess.Popen with CREATE_NO_WINDOW on Windows."""
    kw.setdefault("creationflags", _WNOW)
    return subprocess.Popen(cmd, **kw)

def _run(cmd, **kw) -> subprocess.CompletedProcess:
    """subprocess.run with CREATE_NO_WINDOW on Windows."""
    kw.setdefault("creationflags", _WNOW)
    return subprocess.run(cmd, **kw)

def _check_output(cmd, **kw) -> str:
    """subprocess.check_output with CREATE_NO_WINDOW on Windows."""
    kw.setdefault("creationflags", _WNOW)
    return subprocess.check_output(cmd, **kw)

def _uf(size: int) -> tuple:
    """Return a font tuple that renders Unicode symbols reliably cross-platform."""
    if IS_WINDOWS:
        return ("Segoe UI Emoji", size)
    return ("DejaVu Sans", size)

# ── UI font resolution ──────────────────────────────────────────────────────
# "Segoe UI" doesn't exist outside Windows; on Linux Tk silently falls back
# to whatever generic sans is installed (often a low-quality bitmap font).
# Resolve the best available family per-platform once, at first use, by
# checking what's actually installed via tkinter.font.families().
_UI_FONT_FAMILY: Optional[str] = None
_MONO_FONT_FAMILY: Optional[str] = None

# Ordered by preference. The first entry that's actually installed wins.
_UI_FONT_CANDIDATES = [
    "Segoe UI",            # Windows native
    "Inter",                # clean modern sans, common on Linux installs
    "Noto Sans",            # ships with most modern distros
    "Ubuntu",                # Ubuntu/Pop!_OS default
    "Cantarell",            # GNOME default
    "Helvetica Neue",
    "Liberation Sans",      # near-metric-compatible Arial substitute, very common
    "DejaVu Sans",          # near-universal fallback on Linux
    "Helvetica",
    "Arial",
]
_MONO_FONT_CANDIDATES = [
    "Consolas",             # Windows native
    "JetBrains Mono",
    "Cascadia Code",
    "Fira Code",
    "Source Code Pro",
    "Ubuntu Mono",
    "DejaVu Sans Mono",     # near-universal fallback on Linux
    "Liberation Mono",
    "Courier New",
]

def _resolve_font_family(candidates: List[str], fallback: str) -> str:
    try:
        import tkinter.font as _tkfont
        installed = {f.lower() for f in _tkfont.families()}
        for name in candidates:
            if name.lower() in installed:
                return name
    except Exception:
        pass
    return fallback

def _init_fonts() -> None:
    """Must be called once a Tk root exists (tkinter.font needs a root)."""
    global _UI_FONT_FAMILY, _MONO_FONT_FAMILY
    if _UI_FONT_FAMILY is None:
        _UI_FONT_FAMILY = _resolve_font_family(_UI_FONT_CANDIDATES, "TkDefaultFont")
    if _MONO_FONT_FAMILY is None:
        _MONO_FONT_FAMILY = _resolve_font_family(_MONO_FONT_CANDIDATES, "TkFixedFont")

def UF(size: int, *style) -> tuple:
    """UI font tuple - replaces hardcoded ("Segoe UI", size, ...) everywhere."""
    family = _UI_FONT_FAMILY or "TkDefaultFont"
    return (family, size, *style) if style else (family, size)

def MF(size: int, *style) -> tuple:
    """Monospace font tuple - replaces hardcoded ("Consolas", size, ...)."""
    family = _MONO_FONT_FAMILY or "TkFixedFont"
    return (family, size, *style) if style else (family, size)

# ── Display scale detection ─────────────────────────────────────────────────
# UF()/MF() fonts already scale correctly on their own - they're point-sized,
# and Tk converts points -> pixels using the display's DPI internally. This
# section is only for the things Tk does NOT auto-scale: fixed geometry()
# pixel sizes and the fixed subsample() divisors used for the base64/emoji
# icons. Never multiply a UF()/MF() size by _SCALE - that would double-scale
# the fonts on top of what Tk already does.
_SCALE: float = 1.0

def _detect_scale(root) -> float:
    """
    Best-effort UI scale factor relative to a normal 96-DPI display
    (1.0 = normal, 1.5 = 150%, 2.0 = 200%, ...). Tried in order of how
    trustworthy each signal actually is in practice:
    """
    # 1) An explicit desktop-environment override, if the user has one set.
    #    This is literally what they configured in their display settings,
    #    and is far more trustworthy than anything we can probe for -
    #    especially on Linux, see note in (2) below.
    for var in ("GDK_SCALE", "QT_SCALE_FACTOR"):
        val = os.environ.get(var)
        if val:
            try:
                return max(1.0, float(val))
            except ValueError:
                pass

    # 2) Tk's own scaling value. Tk derives this from the DPI the display
    #    server reports (Xft.dpi on X11), which is exactly what GNOME/KDE
    #    change when the user picks "200%" etc. in Settings - even when
    #    GDK_SCALE isn't set (GDK_SCALE is only exported into GTK-launched
    #    sessions, not into arbitrary processes started from a terminal,
    #    which is the common case here). Tk sets this to screen_DPI / 72.0
    #    at startup, so it's ~1.333 (not 1.0) at a normal 96 DPI -
    #    normalize against that baseline. Checked BEFORE the physical-mm
    #    method below because it reflects the user's *configured* scale,
    #    whereas physical DPI reflects the monitor's fixed hardware
    #    density and has no idea the user asked for 200%.
    try:
        raw = root.tk.call('tk', 'scaling')
        scale = raw / 1.3333333
        if 0.9 <= scale <= 4.0:
            return scale
    except Exception:
        pass

    # 3) Fall back to physical DPI computed from screen pixel dimensions
    #    vs. the physical size Tk reports in millimeters. Only useful when
    #    Xft.dpi/tk-scaling is left at its hardcoded 96-DPI default (no
    #    scaling configured at all) but the panel itself is high-density -
    #    e.g. an unconfigured HiDPI laptop panel.
    try:
        w_px, w_mm = root.winfo_screenwidth(), root.winfo_screenmmwidth()
        if w_mm > 0:
            dpi = w_px / (w_mm / 25.4)
            if dpi > 40:  # sane floor - guards against bogus 0/1mm reports
                scale = dpi / 96.0
                if 0.9 <= scale <= 4.0:
                    return scale
    except Exception:
        pass

    return 1.0

def _init_scale(root) -> None:
    """Call once, as soon as any Tk root exists. Safe to call again later
    (e.g. from a second root) - it just recomputes the same value."""
    global _SCALE
    _SCALE = max(1.0, min(3.0, _detect_scale(root)))

def scaled(px: int) -> int:
    """Scale a raw pixel value (window geometry, padding) for the detected
    display density. Do NOT use this on UF()/MF() font sizes."""
    return round(px * _SCALE)

def scaled_subsample(divisor: int) -> int:
    """Shrink a fixed subsample() divisor on high-DPI displays so icons
    stay physically the same size instead of also shrinking with the
    higher pixel density."""
    return max(1, round(divisor / _SCALE))

# ── Colour palette ─────────────────────────────────────────────────────────────
C_DARK = {
    "bg":       "#0f1117",
    "panel":    "#1a1d27",
    "panel2":   "#22263a",
    "border":   "#2e3350",
    "accent":   "#4f8ef7",
    "accent2":  "#6ee7b7",
    "warn":     "#f59e42",
    "danger":   "#f87171",
    "text":     "#e2e8f0",
    "yumi":     "#fff200",
    "muted":    "#64748b",
    "success":  "#34d399",
    "usb":      "#818cf8",
    "entry_bg": "#1a1d27",
}

C_LIGHT = {
    "bg":       "#f5f5f5",   # site page background
    "panel":    "#ffffff",   # site content/card background
    "panel2":   "#f2f5f7",   # site subtle/widget background
    "border":   "#dddddd",   # site border color
    "accent":   "#0170b9",   # site link/button blue
    "accent2":  "#1a73e8",   # site TOC accent blue (slightly brighter, pairs well)
    "warn":     "#e68a00",   # site buy-now CTA hover orange
    "danger":   "#c0392b",
    "text":     "#3a3a3a",   # site heading color - used as primary text
    "yumi":     "#fff200",
    "muted":    "#4b4f58",   # site body text color - used as secondary text
    "success":  "#1a8754",
    "usb":      "#191970",   # site link hover (midnight blue)
    "entry_bg": "#ffffff",
}

# Set THEME = "light" to use the light palette instead.
# (No in-app toggle - switching themes requires editing this constant and
# restarting, since a live theme switch can't reliably relaunch a compiled
# one-file binary across platforms.)
THEME = "dark"
C = C_LIGHT if THEME == "light" else C_DARK

CAT_ICONS = {
    # Plain Unicode BMP symbols - render reliably on Linux X11 via DejaVu/Noto
    "Linux-ISOs":         "●",   # ●  MEDIUM CIRCLE
    "Tiny-Distros":       "◆",   # ◆  BLACK DIAMOND
    "Anonymous-Browsers": "■",   # ■  BLACK SQUARE
    "AntiVirus-Utilities":"★",   # ★  BLACK STAR
    "System-Tools":       "▲",   # ▲  BLACK UP-POINTING TRIANGLE
    "Penetration-Testing":"☠",   # ☠  SKULL AND CROSSBONES
    "Windows-Installers": "□",   # □  WHITE SQUARE
    "Windows-PE":         "△",   # △  WHITE UP-POINTING TRIANGLE
    "Windows-On-VHD":     "◊",   # ◊  LOZENGE
    "Unlisted ISOs":      "○",   # ○  WHITE CIRCLE
}

CAT_COLORS = {
    "Linux-ISOs":         "#f59e42",
    "Tiny-Distros":       "#6ee7b7",
    "Anonymous-Browsers": "#818cf8",
    "AntiVirus-Utilities":"#f87171",
    "System-Tools":       "#4f8ef7",
    "Penetration-Testing":"#e879f9",
    "Windows-Installers": "#38bdf8",
    "Windows-PE":         "#fb923c",
    "Windows-On-VHD":     "#a3e635",
    "Unlisted ISOs":      "#94a3b8",
}

# ── Load distros.json ──────────────────────────────────────────────────────────
def load_distros() -> Tuple[dict, list]:
    p = BASE_DIR / "distros.json"
    if p.exists():
        with open(p, encoding="utf-8") as f:
            data = json.load(f)
        return data.get("categories", {}), data.get("distros", [])
    return {}, []

CATEGORIES, DISTROS = load_distros()

def match_distro_by_filename(filename: str) -> Optional[dict]:
    """Return the first distros.json entry whose ';'-separated glob `pattern`
    matches filename, or None if nothing matches. Pure/stateless so it can be
    reused for both the manual Browse flow (_auto_match_distro) and the
    multi-file queue (each queued file gets auto-detected the same way)."""
    fname = filename.lower()
    for d in DISTROS:
        for pat in d.get("pattern", "").split(";"):
            if fnmatch.fnmatch(fname, pat.strip().lower()):
                return d
    return None

# ── Drive detection ────────────────────────────────────────────────────────────

def _safe_drive_status(root: Path) -> dict:
    try:
        return drive_status(root)
    except Exception:
        return {"prepared": False, "can_update": False,
                "has_ventoy": False, "has_yumi": False}

def _disk_usage(path: str) -> Tuple[float, float]:
    try:
        s = shutil.disk_usage(path)
        return round(s.used / 1024**3, 2), round(s.total / 1024**3, 2)
    except Exception:
        return 0, 0

def _windows_disk_num_map() -> Dict[str, str]:
    disk_num_map: Dict[str, str] = {}
    try:
        assoc_out = _check_output(
            ["wmic", "path", "Win32_LogicalDiskToPartition", "get",
             "Antecedent,Dependent"],
            text=True, errors="replace", timeout=8,
        )
        for aline in assoc_out.splitlines():
            dm = re.search(r"Disk #(\d+)", aline, re.IGNORECASE)
            lm = re.search(r'LogicalDisk\.DeviceID="([A-Z]:)"', aline, re.IGNORECASE)
            if dm and lm:
                disk_num_map[lm.group(1).upper()] = dm.group(1)
    except Exception:
        pass
    return disk_num_map

def _append_windows_drive(
    drives: List[dict], seen: Set[str], disk_num_map: Dict[str, str],
    dev_id: str, label: str, fs: str, size: int, free: int,
    drive_type: str = "", all_drives: bool = False,
) -> None:
    if not dev_id.endswith(":"):
        return
    key = dev_id.upper()
    if key in seen:
        return
    root = Path(dev_id + "\\")
    if not root.is_dir():
        return
    removable = drive_type == "2"
    if not all_drives and not removable:
        return
    sys_drive    = os.environ.get("SystemDrive", "C:").rstrip("\\").upper() + ":"
    script_drive = str(BASE_DIR.resolve())[0].upper() + ":"
    if key in (sys_drive, script_drive):
        return
    seen.add(key)
    drives.append({
        "path":       dev_id + "\\",
        "label":      (label or dev_id).strip(),
        "fs":         (fs or "?").strip(),
        "total_gb":   round(size / 1024**3, 1) if size else 0,
        "used_gb":    round((size - free) / 1024**3, 1) if size else 0,
        "device":     dev_id,
        "disk_num":   disk_num_map.get(key, ""),
        "mounted":    True,
        "drive_type": drive_type,
        "removable":  removable,
        **_safe_drive_status(root),
    })

def _list_drives_windows_ps(all_drives: bool = False) -> List[dict]:
    script = (
        "Get-WmiObject Win32_LogicalDisk | "
        "Select-Object DeviceID,VolumeName,FileSystem,Size,FreeSpace,DriveType | "
        "ConvertTo-Json -Compress"
    )
    out = _check_output(
        ["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
        text=True, errors="replace", timeout=10,
    ).strip()
    if not out:
        return []
    raw = json.loads(out)
    if isinstance(raw, dict):
        raw = [raw]
    disk_num_map = _windows_disk_num_map()
    drives: List[dict] = []
    seen: Set[str] = set()
    for d in raw:
        dev_id     = (d.get("DeviceID") or "").strip()
        drive_type = str(d.get("DriveType") if d.get("DriveType") is not None else "")
        label      = (d.get("VolumeName") or "").strip()
        fs         = (d.get("FileSystem") or "?").strip()
        size       = int(d.get("Size") or 0)
        free       = int(d.get("FreeSpace") or 0)
        _append_windows_drive(drives, seen, disk_num_map, dev_id, label, fs,
                              size, free, drive_type, all_drives)
    return drives

def _list_drives_windows_wmic(all_drives: bool = False) -> List[dict]:
    out = _check_output(
        ["wmic", "logicaldisk", "get",
         "DeviceID,VolumeName,FileSystem,Size,FreeSpace,DriveType"],
        text=True, errors="replace", timeout=8,
    )
    disk_num_map = _windows_disk_num_map()
    drives: List[dict] = []
    seen: Set[str] = set()
    lines = [ln for ln in out.splitlines() if ln.strip()]
    if len(lines) < 2:
        return drives
    headers = lines[0].split()
    col = {name: idx for idx, name in enumerate(headers)}

    def col_val(parts: List[str], name: str, default: str = "") -> str:
        idx = col.get(name)
        if idx is None or idx >= len(parts):
            return default
        return parts[idx]

    for line in lines[1:]:
        parts = line.split()
        if len(parts) < 3 or not parts[0].endswith(":"):
            continue
        dev_id = parts[0]
        try:
            if "DriveType" in col:
                drive_type = col_val(parts, "DriveType", "")
                fs = col_val(parts, "FileSystem", "?")
                free = int(col_val(parts, "FreeSpace", "0") or "0")
                size = int(col_val(parts, "Size", "0") or "0")
                label = col_val(parts, "VolumeName", "")
            else:
                drive_type = parts[1] if len(parts) > 5 else ""
                fs = parts[2] if len(parts) > 2 else "?"
                free = int(parts[3]) if len(parts) > 3 and parts[3].isdigit() else 0
                size = int(parts[4]) if len(parts) > 4 and parts[4].isdigit() else 0
                label = parts[5] if len(parts) > 5 else ""
        except ValueError:
            continue
        _append_windows_drive(drives, seen, disk_num_map, dev_id, label, fs,
                              size, free, drive_type, all_drives)
    return drives

def _list_drives_windows_letters(all_drives: bool = False) -> List[dict]:
    disk_num_map = _windows_disk_num_map()
    drives: List[dict] = []
    seen: Set[str] = set()
    for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
        dev_id = f"{letter}:"
        root = Path(dev_id + "\\")
        if not root.is_dir():
            continue
        try:
            usage = shutil.disk_usage(root)
            size, free = usage.total, usage.free
        except OSError:
            size, free = 0, 0
        _append_windows_drive(drives, seen, disk_num_map, dev_id, letter, "?",
                              size, free, all_drives=all_drives)
    return drives

def _dedupe_by_disk(drives: List[dict]) -> List[dict]:
    """
    Collapse to one selectable entry per physical disk (by disk_num),
    matching the Linux behaviour of one entry per physical USB drive -
    and matching what classic YUMI.nsi did, which accumulated every
    drive letter on a disk and displayed "(Disk N)" with the data
    partition's volume label/size rather than whichever partition the
    OS happened to enumerate first.

    Picks the most useful partition per disk:
      1. Skip EFI/system partitions (VTOYEFI/EFI/ESP) when a better
         option exists on the same disk - those are tiny and 0-byte-
         looking, not what the user wants to target.
      2. Prefer a partition that already has YUMI/Ventoy on it.
      3. Otherwise prefer the largest partition (most likely the real
         data volume rather than a small hidden/recovery partition).
    All drive letters seen on that disk are accumulated onto the
    winning entry's "letters" list for display.

    Drives with no disk_num (couldn't be mapped) are kept as-is since
    we can't safely group them.
    """
    EFI_LABELS = {"VTOYEFI", "EFI", "ESP"}

    by_disk: Dict[str, List[dict]] = {}
    no_disk: List[dict] = []
    order: List[str] = []
    for d in drives:
        dnum = d.get("disk_num", "")
        if not dnum:
            no_disk.append(d)
            continue
        if dnum not in by_disk:
            by_disk[dnum] = []
            order.append(dnum)
        by_disk[dnum].append(d)

    def _is_efi(d: dict) -> bool:
        return (d.get("label") or "").strip().upper() in EFI_LABELS

    def _rank(d: dict) -> tuple:
        # Higher rank wins. (not-EFI, has-yumi-or-ventoy, size)
        return (
            0 if _is_efi(d) else 1,
            1 if (d.get("has_yumi") or d.get("has_ventoy")) else 0,
            d.get("total_gb", 0) or 0,
        )

    result: List[dict] = []
    for dnum in order:
        parts = by_disk[dnum]
        best = max(parts, key=_rank)
        letters = [(p.get("device") or p.get("path", "")).rstrip("\\")
                   for p in parts if p.get("device") or p.get("path")]
        best = dict(best)  # don't mutate the original
        best["letters"] = letters or [best.get("device", "")]
        result.append(best)
    result.extend(no_disk)
    return result

def list_drives(all_drives: bool = False) -> List[dict]:
    drives: List[dict] = []
    if IS_WINDOWS:
        for fn in (_list_drives_windows_ps,
                   _list_drives_windows_wmic,
                   _list_drives_windows_letters):
            try:
                drives = fn(all_drives)
            except Exception:
                drives = []
            if drives:
                break
        if not drives:
            return [{"error": "No drives found - enter path manually"}]
        drives = _dedupe_by_disk(drives)
    else:
        try:
            out = _check_output(
                ["lsblk", "-J", "-o",
                 "NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINT,RM,TRAN,MODEL"],
                text=True, timeout=5,
            )
            data = json.loads(out)
            script_mnt = ""
            script_dev = ""
            try:
                mp_out = _check_output(
                    ["df", "--output=source,target", str(BASE_DIR)],
                    text=True, timeout=3,
                ).splitlines()
                if len(mp_out) >= 2:
                    parts = mp_out[1].strip().split()
                    if len(parts) >= 2:
                        script_dev = parts[0].strip()   # e.g. /dev/sdb1
                        script_mnt = parts[1].strip().rstrip("/")  # strip trailing /
            except Exception:
                pass

            for dev in data.get("blockdevices", []):
                if dev.get("type") != "disk":
                    continue
                rm   = str(dev.get("rm", "0")).strip() in ("1", "true")
                tran = (dev.get("tran") or "").strip().lower()
                model = (dev.get("model") or "").strip()

                # Standard removable/USB/MMC detection
                is_usb = rm or tran in ("usb", "mmc")

                # VirtualBox passes USB drives through as SCSI/SATA - they won't
                # have rm=1 or tran=usb. Include them if all_drives is set, or if
                # the transport is scsi/sata and the drive isn't the one hosting
                # our script (i.e. not the system/boot disk).
                is_virtual_usb = (
                    not is_usb
                    and tran in ("scsi", "sata", "ata", "")
                    and all_drives
                )

                # Always skip if neither condition met
                if not (is_usb or is_virtual_usb):
                    continue
                parts = dev.get("children", [])
                if parts:
                    best_part = None
                    best_mnt  = ""
                    for p in parts:
                        mnt = (p.get("mountpoint") or "").strip()
                        lbl = (p.get("label") or "").strip().upper()
                        if lbl in ("VTOYEFI", "EFI", "ESP"):
                            continue
                        # Skip the root filesystem partition but NOT the live
                        # media mount - we want to show the booted-from YUMI drive
                        if mnt == "/":
                            continue
                        if best_part is None:
                            best_part = p
                            best_mnt  = mnt
                        elif mnt and not best_mnt:
                            best_part = p
                            best_mnt  = mnt
                    if best_part is None and parts:
                        for p in parts:
                            mnt = (p.get("mountpoint") or "").strip()
                            if not mnt or mnt != "/":
                                best_part = p
                                best_mnt  = mnt
                                break
                    if best_part is not None:
                        p      = best_part
                        mnt    = best_mnt
                        fstype = (p.get("fstype") or "").strip()
                        label  = (p.get("label") or dev.get("label") or "").strip()
                        display = label or dev["name"].upper()
                        if model:
                            display = f"{display} ({model})"

                        # Check /proc/mounts if lsblk shows no mountpoint
                        if not mnt:
                            mnt = _mountpoint_for_device(f"/dev/{p['name']}")
                        # Check our persistent named mount left by run_prepare
                        if not mnt:
                            persist = f"/mnt/yumi-drive-{p['name']}"
                            if os.path.ismount(persist):
                                mnt = persist

                        # Skip drives that aren't mounted - can't safely
                        # read status or write to them without mounting,
                        # and blockdevices (e.g. ISO loop sources) can't
                        # be mounted by the app.
                        if not mnt:
                            continue

                        # Safety: never show the drive the app is running
                        # from as a target. Check both by device path and
                        # by mountpoint being a parent of BASE_DIR.
                        part_dev_path = f"/dev/{p['name']}"
                        running_from = False
                        if script_dev and part_dev_path == script_dev:
                            running_from = True
                        elif mnt and str(BASE_DIR).startswith(mnt.rstrip("/") + "/"):
                            running_from = True
                        if running_from:
                            continue

                        used_gb, total_gb = _disk_usage(mnt)
                        drives.append({
                            "path":      mnt,
                            "label":     display,
                            "fs":        fstype,
                            "total_gb":  total_gb,
                            "used_gb":   used_gb,
                            "device":    f"/dev/{dev['name']}",
                            "partition": part_dev_path,
                            "mounted":   True,
                            **_safe_drive_status(Path(mnt)),
                            "size":      dev.get("size", "?"),
                            "model":     model,
                            "removable": rm or tran in ("usb", "mmc") or is_virtual_usb,
                        })
                else:
                    display = (dev.get("label") or dev["name"].upper())
                    if model:
                        display = f"{display} ({model})"
                    drives.append({
                        "path":      "",
                        "label":     display,
                        "fs":        (dev.get("fstype") or "").strip(),
                        "total_gb":  0,
                        "used_gb":   0,
                        "device":    f"/dev/{dev['name']}",
                        "partition": "",
                        "mounted":   False,
                        "size":      dev.get("size", "?"),
                        "model":     model,
                        "removable": rm or tran in ("usb", "mmc") or is_virtual_usb,
                        "prepared":  False, "can_update": False,
                        "has_ventoy": False, "has_yumi": False,
                    })
        except Exception as e:
            drives.append({"error": str(e)})
    return drives


# ── Mountpoint / drive helpers ─────────────────────────────────────────────────

def _mountpoint_for_device(dev: str) -> str:
    try:
        with open("/proc/mounts") as f:
            for line in f:
                parts = line.split()
                if len(parts) >= 2 and parts[0] == dev:
                    return parts[1]
    except OSError:
        pass
    return ""

_temp_mounts: Dict[str, str] = {}

def drive_root(path_or_dev: str, readonly: bool = False) -> Optional[Path]:
    p = Path(path_or_dev)
    if p.is_dir():
        return p
    if not str(p).startswith("/dev/"):
        return None
    # 1. Already mounted - check /proc/mounts for the device and its partitions
    for candidate in (str(p), str(p) + "1", str(p) + "p1"):
        mnt = _mountpoint_for_device(candidate)
        if mnt:
            mp = Path(mnt)
            if mp.is_dir():
                return mp
    # 2. Check for our persistent named mount left by run_prepare
    for suffix in ("1", "p1", ""):
        devname = Path(str(p) + suffix).name
        persist = Path(f"/mnt/yumi-drive-{devname}")
        if persist.is_mount():
            return persist
    # 3. Try udisksctl - lets the desktop automounter own the mount path
    if shutil.which("udisksctl"):
        for part in (str(p) + "1", str(p) + "p1", str(p)):
            if not Path(part).exists():
                continue
            try:
                r = _run(
                    ["udisksctl", "mount", "--block-device", part,
                     "--no-user-interaction"],
                    capture_output=True, text=True, timeout=8,
                )
                if r.returncode == 0:
                    import re as _re
                    m = _re.search(r"at (.+?)\.?$", r.stdout.strip())
                    if m:
                        mp = Path(m.group(1).rstrip("."))
                        if mp.is_dir():
                            return mp
            except Exception:
                pass
    # 4. Manual mount fallback into a named temp dir
    for part in (str(p) + "1", str(p) + "p1"):
        part_path = Path(part)
        if not part_path.exists():
            continue
        tmp_mnt = f"/mnt/yumi-{'ro' if readonly else 'rw'}-{part_path.name}"
        try:
            os.makedirs(tmp_mnt, exist_ok=True)
            opts = "ro" if readonly else "rw"
            r = _run(["mount", "-o", opts, part, tmp_mnt],
                               capture_output=True, timeout=5)
            if r.returncode == 0:
                _temp_mounts[tmp_mnt] = part
                return Path(tmp_mnt)
            try:
                os.rmdir(tmp_mnt)
            except OSError:
                pass
        except Exception:
            pass
    return None

def drive_root_cleanup(drive: Path):
    key = str(drive)
    if key in _temp_mounts:
        try:
            _run(["umount", key], capture_output=True, timeout=5)
        except Exception:
            pass
        try:
            os.rmdir(key)
        except OSError:
            pass
        _temp_mounts.pop(key, None)

def yumi_root(drive: Path) -> Path:
    return drive / "YUMI"

def installed_txt(drive: Path) -> Path:
    return yumi_root(drive) / "Installed.txt"

def read_installed(drive: Path) -> List[str]:
    p = installed_txt(drive)
    if not p.exists():
        return []
    return [l.strip() for l in p.read_text(encoding="utf-8", errors="replace")
            .splitlines() if l.strip()]

def write_installed(drive: Path, lines: List[str]):
    p = installed_txt(drive)
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text("\r\n".join(lines) + ("\r\n" if lines else ""), encoding="utf-8")

def scan_installed(drive: Path) -> List[str]:
    entries = read_installed(drive)
    seen = {e.lower().replace("/", "\\") for e in entries}
    yumi = yumi_root(drive)
    if not yumi.is_dir():
        return entries
    try:
        for cat_dir in sorted(yumi.iterdir()):
            if not cat_dir.is_dir():
                continue
            for pattern in ("*.iso", "*.vhdx", "*.img"):
                for iso_file in cat_dir.glob(pattern):
                    line = f"{cat_dir.name}\\{iso_file.name}"
                    key = line.lower()
                    if key not in seen:
                        entries.append(line)
                        seen.add(key)
    except OSError:
        pass
    return entries

def _ventoy_persistence_backends(drive: Path) -> Dict[str, str]:
    """Map ventoy.json image path -> backend filename."""
    out: Dict[str, str] = {}
    try:
        vjp = ventoy_json_path(drive)
        if not vjp.exists():
            return out
        data = json.loads(vjp.read_text(encoding="utf-8", errors="replace"))
        for entry in data.get("persistence", []):
            if not isinstance(entry, dict):
                continue
            image   = entry.get("image", "")
            backend = entry.get("backend", "")
            if image and backend:
                out[image.replace("\\", "/")] = backend.rsplit("/", 1)[-1]
    except Exception:
        pass
    return out

def scan_installed_details(drive: Path) -> List[dict]:
    """Installed entries enriched with on-disk persistence sidecar detection."""
    persist_map = _ventoy_persistence_backends(drive)
    results: List[dict] = []
    for line in scan_installed(drive):
        norm  = line.replace("/", "\\")
        parts = norm.split("\\", 1)
        cat   = parts[0] if len(parts) == 2 else "Linux-ISOs"
        fname = parts[1] if len(parts) == 2 else parts[0]
        stem  = Path(fname).stem
        backend = ""
        cat_dir = yumi_root(drive) / cat
        for ext in (".rw", ".persistence"):
            sidecar = cat_dir / f"{stem}{ext}"
            if sidecar.is_file():
                backend = f"{stem}{ext}"
                break
        if not backend:
            vimg = f"/YUMI/{cat}/{fname}"
            backend = persist_map.get(vimg, "")
        results.append({
            "installedLine": norm,
            "category":      cat,
            "filename":      fname,
            "persist":       bool(backend),
            "backendFile":   backend,
        })
    return results

def has_ventoy_bootloader(drive: Path) -> bool:
    ventoy = drive / "ventoy"
    if not ventoy.is_dir():
        return False
    for marker in (ventoy / "ventoy.json", ventoy / "theme", ventoy / "version"):
        if marker.exists():
            return True
    try:
        return any(ventoy.iterdir())
    except OSError:
        return False

def has_yumi_folder(drive: Path) -> bool:
    return yumi_root(drive).is_dir()

def drive_prepared(drive: Path) -> bool:
    return has_ventoy_bootloader(drive) and has_yumi_folder(drive)

def drive_has_yumi_or_ventoy(drive: Path) -> bool:
    yumi = yumi_root(drive)
    if yumi.is_dir():
        try:
            if any(yumi.iterdir()):
                return True
        except OSError:
            pass
    return has_ventoy_bootloader(drive)

def drive_status(drive: Path) -> dict:
    vt   = has_ventoy_bootloader(drive)
    yumi = has_yumi_folder(drive)
    return {
        "has_ventoy":  vt,
        "has_yumi":    yumi,
        "prepared":    drive_prepared(drive),
        "can_update":  drive_has_yumi_or_ventoy(drive),
    }

def resolve_prepare_mode(drive: Path, requested: str) -> str:
    return "update" if drive_prepared(drive) else "install"

def ventoy_json_path(drive: Path) -> Path:
    v = drive / "ventoy" / "ventoy.json"
    if v.parent.exists():
        return v
    return yumi_root(drive) / "ventoy.json"

def is_elevated() -> bool:
    if IS_WINDOWS:
        try:
            import ctypes
            return bool(ctypes.windll.shell32.IsUserAnAdmin())
        except Exception:
            return False
    return os.geteuid() == 0


# ── Persistence helpers ────────────────────────────────────────────────────────
LEGACY_UBUNTU = ["buntu-16", "buntu-17", "buntu-18", "buntu-19"]
LEGACY_MINT   = ["mint-16",  "mint-17",  "mint-18",  "mint-19"]

def casper_label(distro_name: str, iso_name: str) -> str:
    if distro_name in ("Ubuntu", "Linux Lite"):
        return "casper-rw" if any(m in iso_name for m in LEGACY_UBUNTU) else "writable"
    if distro_name == "Linux Mint":
        return "casper-rw" if any(m in iso_name for m in LEGACY_MINT) else "writable"
    if distro_name in ("Kaspersky Rescue Disk (Antivirus Scanner)", "Zorin OS"):
        return "casper-rw"
    if distro_name == "MX Linux":
        return "MX-Persist"
    if distro_name in ("Debian Live", "Raspberry Pi Desktop",
                       "Clonezilla (Backup + Clone Tool)",
                       "Kali Linux (Penetration Testing)", "Linux Mint Debian"):
        return "persistence"
    if distro_name in ("Archlinux", "Fedora"):
        return "vtoycow"
    return "writable"

def backend_filename(iso_name: str, label: str) -> str:
    base = Path(iso_name).stem
    return f"{base}.persistence" if label == "persistence" else f"{base}.rw"

def create_zero_filled_file(path: Path, size_mb: int, q: Optional[queue.Queue] = None) -> None:
    """Create a non-sparse zero-filled file (Windows persistence; replaces dd)."""
    path.parent.mkdir(parents=True, exist_ok=True)
    chunk = 1024 * 1024
    with open(path, "wb") as fp:
        for i in range(size_mb):
            fp.write(b"\x00" * chunk)
            if q is not None:
                pct = int((i + 1) / size_mb * 100)
                q.put({"type": "progress", "pct": pct,
                       "label": f"Writing persistence file ... {i+1}/{size_mb} MB"})


# ── sudo helper (Linux only) ───────────────────────────────────────────────────
def _ask_sudo_password(prompt: str = "This action requires administrator (sudo) privileges.\n\nEnter your password:") -> Optional[str]:
    """Show a Tk password dialog on the calling thread and return the entered
    password, or None if the user cancelled. Must be called from the main thread."""
    result: list = [None]
    dlg = Toplevel()
    dlg.title("Administrator Password")
    dlg.resizable(False, False)
    dlg.configure(bg=C["bg"])
    dlg.grab_set()

    tk.Label(dlg, text=prompt, bg=C["bg"], fg=C["text"],
             font=UF(10), justify="left", wraplength=340).pack(padx=20, pady=(18, 8))

    entry = Entry(dlg, show="•", bg=C["entry_bg"], fg=C["text"],
                  insertbackground=C["text"], font=UF(11), width=28,
                  relief="flat", bd=4)
    entry.pack(padx=20, pady=(0, 14))
    entry.focus_set()

    btn_row = tk.Frame(dlg, bg=C["bg"])
    btn_row.pack(pady=(0, 16))

    def _ok(event=None):
        result[0] = entry.get()
        dlg.destroy()

    def _cancel(event=None):
        dlg.destroy()

    tk.Button(btn_row, text="Cancel", command=_cancel,
              bg=C["panel2"], fg=C["muted"], relief="flat",
              font=UF(10), padx=12, pady=4).pack(side="left", padx=6)
    tk.Button(btn_row, text="OK", command=_ok,
              bg=C["accent"], fg=C["bg"], relief="flat",
              font=UF(10, "bold"), padx=12, pady=4).pack(side="left", padx=6)

    dlg.bind("<Return>", _ok)
    dlg.bind("<Escape>", _cancel)

    # Centre over the root window
    dlg.update_idletasks()
    try:
        root = dlg.nametowidget(".")
        rx = root.winfo_rootx() + root.winfo_width() // 2 - dlg.winfo_width() // 2
        ry = root.winfo_rooty() + root.winfo_height() // 2 - dlg.winfo_height() // 2
        dlg.geometry(f"+{rx}+{ry}")
    except Exception:
        pass

    dlg.wait_window()
    return result[0]


def sudo_script(script: str, q: queue.Queue) -> int:
    """Write script to a temp file and run it with sudo/pkexec, streaming lines to q.

    Elevation strategy (Linux, non-root):
      1. Already root                 → run directly.
      2. sudo with cached credentials → sudo -E (passwordless, silent).
      3. pkexec available             → pkexec shows a native GUI auth dialog.
      4. sudo available, no cache     → prompt for password via a Tk dialog and
                                        feed it to sudo via stdin (-S flag).
      5. Nothing works               → error message with manual fallback hint.
    """
    if IS_WINDOWS:
        q.put({"type": "error", "text": "sudo_script() is not supported on Windows."})
        return 1
    fd, tmp = tempfile.mkstemp(suffix=".sh", prefix="yumi-")
    try:
        with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
            f.write("#!/usr/bin/env bash\n")
            f.write(script)
        os.chmod(tmp, 0o700)

        if os.geteuid() == 0:
            # Already root - run directly.
            cmd = ["bash", tmp]
            proc = _popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                          text=True, bufsize=1)
            for line in proc.stdout:
                q.put({"type": "log", "text": line.rstrip()})
            proc.wait()
            return proc.returncode

        has_sudo   = bool(shutil.which("sudo"))
        has_pkexec = bool(shutil.which("pkexec"))

        # Try passwordless sudo first (cached credentials / NOPASSWD rule).
        if has_sudo:
            test = _run(["sudo", "-n", "true"], capture_output=True, timeout=3)
            if test.returncode == 0:
                cmd = ["sudo", "-E", "bash", tmp]
                proc = _popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              text=True, bufsize=1)
                for line in proc.stdout:
                    q.put({"type": "log", "text": line.rstrip()})
                proc.wait()
                return proc.returncode

        # pkexec shows a native graphical auth dialog - no password handling needed.
        if has_pkexec:
            cmd = ["pkexec", "bash", tmp]
            proc = _popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                          text=True, bufsize=1)
            for line in proc.stdout:
                q.put({"type": "log", "text": line.rstrip()})
            proc.wait()
            return proc.returncode

        # sudo is available but needs a password - prompt via a Tk dialog.
        if has_sudo:
            # The dialog must run on the main thread; post a sentinel to the queue
            # so the GUI pump knows to call _ask_sudo_password and put the result
            # back. We use a threading.Event + shared list for the round-trip.
            pw_result: list = [None]
            pw_event = threading.Event()
            q.put({"type": "sudo_prompt", "result": pw_result, "event": pw_event})
            pw_event.wait(timeout=120)  # wait up to 2 min for user input
            password = pw_result[0]
            if password is None:
                q.put({"type": "error", "text": "Password prompt cancelled."})
                return 1
            # Feed password via stdin; sudo -S reads it, then runs the script.
            proc = _popen(
                ["sudo", "-SE", "bash", tmp],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1,
            )
            try:
                proc.stdin.write(password + "\n")
                proc.stdin.close()
            except BrokenPipeError:
                pass
            for line in proc.stdout:
                stripped = line.rstrip()
                # sudo echoes "[sudo] password for …" to stderr (merged here) -
                # suppress that line so it doesn't appear in the log panel.
                if stripped.lower().startswith("[sudo]"):
                    continue
                q.put({"type": "log", "text": stripped})
            proc.wait()
            if proc.returncode == 1:
                # sudo exits 1 for wrong password - give a clear message.
                q.put({"type": "error", "text": "Incorrect password - operation cancelled."})
            return proc.returncode

        q.put({"type": "error",
               "text": "No sudo or pkexec found. Run: sudo ./yumi_py"})
        return 1
    finally:
        try:
            os.unlink(tmp)
        except OSError:
            pass


# ── Windows Ventoy helper ──────────────────────────────────────────────────────
def _find_ventoy2disk(tmp_dir: Path) -> Optional[Path]:
    """Locate Ventoy2Disk.exe - prefer top-level (not altexe/)."""
    for vdir in sorted(tmp_dir.glob("ventoy-*")):
        for name in ("Ventoy2Disk.exe", "Ventoy2Disk_X64.exe"):
            candidate = vdir / name
            if candidate.is_file():
                return candidate
    for name in ("Ventoy2Disk.exe", "Ventoy2Disk_X64.exe"):
        for candidate in tmp_dir.rglob(name):
            if "altexe" not in candidate.parts:
                return candidate
    for name in ("Ventoy2Disk.exe", "Ventoy2Disk_X64.exe"):
        for candidate in tmp_dir.rglob(name):
            return candidate
    return None


# ── Job runners ────────────────────────────────────────────────────────────────

def run_vhd_install(body: dict, q: queue.Queue):
    """
    Install a Windows-On-VHD distro (Phoenix LiteOS / Windows X-Lite).
    Matches NSIS InstallDistro.nsh VHD path:
      1. Ensure ventoy_vhdboot.img is on the drive
      2. Create .vhdx via diskpart, attach, format NTFS, find drive letter
      3. Extract ISO with 7zG.exe
      4. Apply install.wim/.esd via DISM (preferred) or wimlib-imagex
      5. Copy AutoUnattend.xml to VHD root
      6. Detach VHD, copy .vhdx to drive, update Installed.txt, clean up
    Windows-only - diskpart/DISM require Windows.
    """
    drive_path  = body.get("drive", "")
    iso_name    = body.get("isoName", "")
    iso_source  = body.get("isoPath", "")
    vhd_size_mb = int(body.get("vhdSizeMb", 30000))
    category    = "Windows-On-VHD"

    def log(msg, typ="log"):
        q.put({"type": typ, "text": msg})

    if not IS_WINDOWS:
        log("VHD install is only supported on Windows.", "error")
        q.put({"type": "done", "rc": 1})
        return

    if not is_elevated():
        log("Administrator privileges are required for VHD install.", "error")
        log("Right-click yumi_py.exe → Run as administrator.", "warn")
        q.put({"type": "done", "rc": 1})
        return

    drive = drive_root(drive_path)
    if not drive:
        log(f"Drive path not found: {drive_path}", "error")
        q.put({"type": "done", "rc": 1})
        return

    if not iso_source or not Path(iso_source).is_file():
        log(f"ISO not found: {iso_source}", "error")
        q.put({"type": "done", "rc": 1})
        return

    TOOLS        = BASE_DIR / "YUMI4Windows" / "tools"
    seven_zip    = TOOLS / "7zG.exe"
    wimlib       = TOOLS / "wimlib" / "wimlib-imagex.exe"
    unattend_src = BASE_DIR / "YUMI4Windows" / "AutoUnattend.xml"
    vhdboot_url  = "https://github.com/ventoy/vhdiso/releases/latest/download/ventoy_vhdboot.zip"

    iso_stem   = Path(iso_name).stem
    vhdx_name  = f"{iso_stem}.vhdx"
    dest_dir   = yumi_root(drive) / category
    dest_vhdx  = dest_dir / vhdx_name
    ventoy_dir = drive / "ventoy"
    vhdboot    = ventoy_dir / "ventoy_vhdboot.img"

    if dest_vhdx.exists() and not body.get("forceReplace"):
        log(f"{category}\\{vhdx_name} is already on the drive. Remove it first!", "error")
        q.put({"type": "done", "rc": 1})
        return

    tmp_dir  = Path(tempfile.mkdtemp(prefix="yumi-vhd-"))
    vhdx_tmp = tmp_dir / vhdx_name
    diskpart_create = tmp_dir / "create_vhd.txt"
    diskpart_detach = tmp_dir / "detach_vhd.txt"
    vhd_label = "Win11"

    def detach_vhd():
        """Best-effort VHD detach - called on both success and failure paths."""
        try:
            _run(["diskpart", "/S", str(diskpart_detach)], capture_output=True)
        except Exception:
            pass

    try:
        # ── Step 1: ventoy_vhdboot.img ────────────────────────────────────────
        if not vhdboot.exists():
            log("Downloading ventoy_vhdboot.zip ...")
            ventoy_dir.mkdir(parents=True, exist_ok=True)
            zip_tmp = tmp_dir / "ventoy_vhdboot.zip"

            def _dl_progress(count, block, total):
                if total > 0:
                    pct = min(int(count * block / total * 100), 100)
                    q.put({"type": "progress", "pct": pct,
                           "label": f"Downloading ventoy_vhdboot.zip ... {pct}%"})

            urllib.request.urlretrieve(vhdboot_url, zip_tmp, _dl_progress)
            log("Extracting ventoy_vhdboot.zip ...")
            with zipfile.ZipFile(zip_tmp) as zf:
                zf.extractall(tmp_dir / "vhdboot")
            # Path inside zip: ventoy_vhdboot/Win10Based/ventoy_vhdboot.img
            img_src = (tmp_dir / "vhdboot" / "ventoy_vhdboot"
                       / "Win10Based" / "ventoy_vhdboot.img")
            if not img_src.exists():
                found = list((tmp_dir / "vhdboot").rglob("ventoy_vhdboot.img"))
                if not found:
                    log("ventoy_vhdboot.img not found in downloaded zip.", "error")
                    q.put({"type": "done", "rc": 1})
                    return
                img_src = found[0]
            shutil.copy2(img_src, vhdboot)
            log(f"ventoy_vhdboot.img installed to {vhdboot}", "success")
        else:
            log("ventoy_vhdboot.img already present.")

        # ── Step 2: Write diskpart scripts ────────────────────────────────────
        diskpart_create.write_text(
            f'create vdisk file="{vhdx_tmp}" type=expandable maximum={vhd_size_mb}\n'
            f'select vdisk file="{vhdx_tmp}"\n'
            f'attach vdisk\n'
            f'create par pri\n'
            f'assign letter=B\n'
            f'format fs=ntfs quick label={vhd_label}\n'
            f'active\n'
            f'exit\n',
            encoding="utf-8",
        )
        diskpart_detach.write_text(
            f'select vdisk file="{vhdx_tmp}"\n'
            f'detach vdisk\n'
            f'exit\n',
            encoding="utf-8",
        )

        # ── Step 3: Create and attach VHDX ────────────────────────────────────
        log(f"Creating {vhd_size_mb} MB VHDX ...")
        r = _run(["diskpart", "/S", str(diskpart_create)],
                 capture_output=True, text=True)
        if r.returncode != 0:
            log(f"diskpart create failed:\n{r.stdout}\n{r.stderr}", "error")
            q.put({"type": "done", "rc": 1})
            return
        log("VHDX created and attached.", "success")

        # ── Step 4: Find VHD drive letter via PowerShell ──────────────────────
        ps = (
            f"Get-Volume | Where-Object {{ $_.FileSystemLabel -eq '{vhd_label}' }} "
            f"| Select-Object -ExpandProperty DriveLetter"
        )
        r2 = _run(["powershell", "-NoProfile", "-Command", ps],
                  capture_output=True, text=True)
        vhd_letter = r2.stdout.strip().upper()
        if not vhd_letter or len(vhd_letter) != 1 or not vhd_letter.isalpha():
            log(f"Could not determine VHD drive letter (got: {r2.stdout!r}). "
                f"Drive letter B may already be in use - free it and retry.", "error")
            detach_vhd()
            q.put({"type": "done", "rc": 1})
            return
        vhd_drive = Path(f"{vhd_letter}:\\")
        log(f"VHD attached as {vhd_letter}:\\", "success")

        # ── Step 5: Extract ISO ───────────────────────────────────────────────
        extract_dir = tmp_dir / "iso_contents"
        extract_dir.mkdir()
        if not seven_zip.exists():
            log(f"7zG.exe not found at {seven_zip} - cannot extract ISO.", "error")
            detach_vhd()
            q.put({"type": "done", "rc": 1})
            return

        log(f"Extracting {iso_name} ...")
        r3 = _popen(
            [str(seven_zip), "x", str(iso_source), f"-o{extract_dir}", "-y"],
            stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
        )
        for line in r3.stdout:
            line = line.rstrip()
            if line:
                log(line)
        r3.wait()
        if r3.returncode not in (0, 1):  # 7z returns 1 for warnings, 0 for ok
            log(f"7z extraction failed (exit {r3.returncode}).", "error")
            detach_vhd()
            q.put({"type": "done", "rc": 1})
            return
        log("ISO extracted.", "success")

        # Copy AutoUnattend.xml into extracted ISO dir
        if unattend_src.exists():
            shutil.copy2(unattend_src, extract_dir / "unattend.xml")
            log("Copied AutoUnattend.xml to ISO dir.")

        # ── Step 6: Apply install.wim or install.esd ──────────────────────────
        sources_dir = extract_dir / "sources"
        wim_file    = sources_dir / "install.wim"
        esd_file    = sources_dir / "install.esd"
        img_file    = wim_file if wim_file.exists() else (
                      esd_file if esd_file.exists() else None)

        if img_file is None:
            log("No install.wim or install.esd found in extracted ISO.", "error")
            detach_vhd()
            q.put({"type": "done", "rc": 1})
            return

        dism = Path(os.environ.get("WINDIR", r"C:\Windows")) / "System32" / "Dism.exe"

        if dism.exists():
            # Query image info - show available indexes
            log(f"Querying {img_file.name} ...")
            ri = _run([str(dism), "/Get-ImageInfo", f"/imagefile:{img_file}"],
                      capture_output=True, text=True)
            for line in ri.stdout.splitlines():
                if any(k in line for k in ("Index", "Name", "Description", "Size")):
                    log(f"  {line.strip()}")

            log(f"Applying {img_file.name} index 1 to {vhd_letter}:\\ via DISM ...")
            log("This may take several minutes ...")
            rw = _popen(
                [str(dism), "/Apply-Image",
                 f"/ImageFile:{img_file}", "/index:1",
                 f"/ApplyDir:{vhd_letter}:\\"],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
            )
            for line in rw.stdout:
                line = line.rstrip()
                if line:
                    log(line)
                    if line.strip().endswith("%"):
                        try:
                            pct = int(line.strip().rstrip("%").split()[-1])
                            q.put({"type": "progress", "pct": pct,
                                   "label": f"Applying image ... {pct}%"})
                        except ValueError:
                            pass
            rw.wait()
            if rw.returncode != 0:
                log(f"DISM apply failed (exit {rw.returncode}).", "error")
                detach_vhd()
                q.put({"type": "done", "rc": 1})
                return

        elif wimlib.exists():
            log(f"DISM not found - using wimlib to apply {img_file.name} ...")
            rw = _popen(
                [str(wimlib), "apply", str(img_file), "1", f"{vhd_letter}:\\"],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
            )
            for line in rw.stdout:
                line = line.rstrip()
                if line:
                    log(line)
            rw.wait()
            if rw.returncode != 0:
                log(f"wimlib apply failed (exit {rw.returncode}).", "error")
                detach_vhd()
                q.put({"type": "done", "rc": 1})
                return
        else:
            log("Neither DISM (System32\\Dism.exe) nor wimlib-imagex.exe found.", "error")
            detach_vhd()
            q.put({"type": "done", "rc": 1})
            return

        log("Image applied successfully.", "success")

        # ── Step 7: Copy AutoUnattend.xml to VHD root ─────────────────────────
        if unattend_src.exists():
            try:
                shutil.copy2(unattend_src, vhd_drive / "unattend.xml")
                log("Copied AutoUnattend.xml to VHD root.")
            except Exception as e:
                log(f"AutoUnattend.xml copy to VHD root failed: {e}", "warn")

        # ── Step 8: Detach VHD ────────────────────────────────────────────────
        log("Detaching VHD ...")
        detach_vhd()
        log("VHD detached.", "success")

        # ── Step 9: Copy .vhdx to USB drive ──────────────────────────────────
        dest_dir.mkdir(parents=True, exist_ok=True)
        log(f"Copying {vhdx_name} to USB drive ...")
        vhdx_size = vhdx_tmp.stat().st_size
        chunk     = 4 * 1024 * 1024
        copied    = 0
        with open(vhdx_tmp, "rb") as src, open(dest_vhdx, "wb") as dst:
            while True:
                buf = src.read(chunk)
                if not buf:
                    break
                dst.write(buf)
                copied += len(buf)
                if vhdx_size > 0:
                    pct = int(copied / vhdx_size * 100)
                    q.put({"type": "progress", "pct": pct,
                           "label": f"Copying VHDX ... "
                                    f"{copied // (1024*1024)}/{vhdx_size // (1024*1024)} MB"})
        log(f"Copied {vhdx_name} → {dest_vhdx}", "success")

        # ── Step 10: Update Installed.txt ─────────────────────────────────────
        entry = f"{category}\\{vhdx_name}"
        lines = read_installed(drive)
        if entry not in lines:
            lines.append(entry)
            write_installed(drive, lines)
            log(f"Added to Installed.txt: {entry}", "success")

    except Exception as e:
        log(f"VHD install error: {e}", "error")
        detach_vhd()
        q.put({"type": "done", "rc": 1})
        return
    finally:
        shutil.rmtree(tmp_dir, ignore_errors=True)

    log("Done.", "success")
    q.put({"type": "done", "rc": 0})


def run_install(body: dict, q: queue.Queue):
    drive_path  = body.get("drive", "")
    iso_name    = body.get("isoName", "")
    category    = body.get("category", "Linux-ISOs")
    distro_name = body.get("distroName", "")
    persist_mb  = int(body.get("persistMb", 0))
    iso_source  = body.get("isoPath", "")

    def log(msg, typ="log"):
        q.put({"type": typ, "text": msg})

    drive = drive_root(drive_path)
    if not drive:
        log(f"Drive path not found or not mounted: {drive_path}", "error")
        q.put({"type": "done", "rc": 1})
        return

    dest_dir = yumi_root(drive) / category
    dest_dir.mkdir(parents=True, exist_ok=True)
    dest_iso = dest_dir / iso_name

    if dest_iso.exists() and not body.get("forceReplace"):
        log(f"{category}\\{iso_name} is already on the drive. Remove it first!", "error")
        q.put({"type": "done", "rc": 1})
        return

    if not iso_source or not Path(iso_source).is_file():
        log(f"ISO source not found at: {iso_source}", "error")
        q.put({"type": "done", "rc": 1})
        return

    log(f"Copying {iso_name} to {dest_dir} ...")
    try:
        src_size = Path(iso_source).stat().st_size
        chunk    = 4 * 1024 * 1024
        copied   = 0
        with open(iso_source, "rb") as src, open(dest_iso, "wb") as dst:
            while True:
                buf = src.read(chunk)
                if not buf:
                    break
                dst.write(buf)
                copied += len(buf)
                if src_size > 0:
                    pct       = int(copied / src_size * 100)
                    copied_mb = copied // (1024 * 1024)
                    total_mb  = src_size // (1024 * 1024)
                    q.put({"type": "progress", "pct": pct,
                           "label": f"Copying ISO ... {copied_mb}/{total_mb} MB"})
        shutil.copystat(iso_source, dest_iso)
        log(f"Copied {iso_name} -> {dest_iso}", "success")
    except Exception as e:
        log(f"Copy failed: {e}", "error")
        q.put({"type": "done", "rc": 1})
        return

    entry = f"{category}\\{iso_name}"
    lines = read_installed(drive)
    if entry not in lines:
        lines.append(entry)
        write_installed(drive, lines)
        log(f"Added to Installed.txt: {entry}", "success")

    if persist_mb > 0:
        label        = casper_label(distro_name, iso_name)
        backend      = backend_filename(iso_name, label)
        persist_path = dest_dir / backend

        try:
            if IS_WINDOWS:
                TOOLS      = BASE_DIR / "YUMI4Windows" / "tools"
                mke2fs_exe = TOOLS / "mke2fs.exe"
                # Fallback: when running as a compiled .exe, also check next to
                # the executable itself (useful if mke2fs.exe is shipped alongside
                # yumi_py.exe rather than embedded in the Nuitka data dir).
                if not mke2fs_exe.exists():
                    _exe_dir   = Path(sys.executable).resolve().parent
                    _fallbacks = [
                        _exe_dir / "YUMI4Windows" / "tools" / "mke2fs.exe",
                        _exe_dir / "mke2fs.exe",
                    ]
                    for _fb in _fallbacks:
                        if _fb.exists():
                            mke2fs_exe = _fb
                            TOOLS      = _fb.parent
                            break

                if label == "persistence":
                    # ── Debian-style: extract pre-made ext4 image from zip ────
                    # Matches NSIS CasperScript: pick the smallest zip that fits
                    # the requested size, extract persistence.persistence, rename.
                    if persist_mb <= 8192:
                        zip_name  = "8gpersistence.zip"
                        zip_label = "8 GB"
                    elif persist_mb <= 16384:
                        zip_name  = "16gpersistence.zip"
                        zip_label = "16 GB"
                    else:
                        zip_name  = "32gpersistence.zip"
                        zip_label = "32 GB"

                    zip_path = TOOLS / zip_name
                    if not zip_path.exists():
                        log(f"{zip_name} not found at {zip_path} - cannot create persistence.",
                            "error")
                        raise FileNotFoundError(zip_path)

                    log(f"Extracting {zip_label} persistence image ({zip_name}) ...")
                    q.put({"type": "progress", "pct": 0,
                           "label": f"Extracting {zip_label} persistence image ..."})
                    with zipfile.ZipFile(zip_path) as zf:
                        # The zip contains a single file: persistence.persistence
                        inner = zf.namelist()[0]
                        total_bytes = zf.getinfo(inner).file_size
                        extracted   = 0
                        chunk       = 4 * 1024 * 1024
                        with zf.open(inner) as src, open(persist_path, "wb") as dst:
                            while True:
                                buf = src.read(chunk)
                                if not buf:
                                    break
                                dst.write(buf)
                                extracted += len(buf)
                                if total_bytes > 0:
                                    pct = int(extracted / total_bytes * 100)
                                    done_mb  = extracted  // (1024 * 1024)
                                    total_mb = total_bytes // (1024 * 1024)
                                    q.put({"type": "progress", "pct": pct,
                                           "label": f"Extracting persistence ... {done_mb}/{total_mb} MB"})

                    log(f"Persistence image extracted: {persist_path}", "success")

                else:
                    # ── casper-rw / writable / MX-Persist / vtoycow ──────────
                    # Create zero-filled file then format with mke2fs.exe
                    abs_path = str(persist_path.resolve())
                    log(f"Creating {persist_mb} MB persistence file ...")
                    try:
                        create_zero_filled_file(persist_path, persist_mb, q)
                        log(f"Raw file created: {abs_path}", "success")
                    except Exception as fe:
                        log(f"Persistence file creation failed: {fe}", "error")
                        raise

                    if mke2fs_exe.exists():
                        log(f"Formatting as ext4 with label {label!r} ...")
                        mk_proc = _popen(
                            [str(mke2fs_exe), "-t", "ext4", "-F", "-L", label, abs_path],
                            cwd=str(TOOLS),
                            stdin=subprocess.PIPE,
                            stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                            text=True, bufsize=1,
                        )
                        mk_proc.stdin.write("\n")
                        mk_proc.stdin.close()
                        for line in mk_proc.stdout:
                            line = line.rstrip()
                            if line:
                                log(line)
                        mk_proc.wait()
                        if mk_proc.returncode == 0:
                            log(f"Formatted {abs_path}", "success")
                        else:
                            log(f"mke2fs exited {mk_proc.returncode}", "warn")
                    else:
                        log(f"mke2fs.exe not found - file created but not formatted as ext4. "
                            f"Expected at: {TOOLS / 'mke2fs.exe'}", "warn")

            else:  # Linux
                script = f"""\
set -uo pipefail
echo "Creating {persist_mb} MiB persistence ({label}) ..."
dd if=/dev/zero of={json.dumps(str(persist_path))} bs=1M count={persist_mb} status=progress 2>&1
mke2fs -t ext4 -F -L {json.dumps(label)} {json.dumps(str(persist_path))} 2>&1
"""
                if label == "persistence":
                    script += f"""\
PTMP=$(mktemp -d)
mount -o loop {json.dumps(str(persist_path))} "$PTMP"
echo "/ union" > "$PTMP/persistence.conf"
umount "$PTMP"; rmdir "$PTMP"
"""
                script += 'echo "Persistence ready."\n'
                rc = sudo_script(script, q)
                if rc != 0:
                    log(f"Persistence script exited {rc}", "warn")

            # Update ventoy.json
            try:
                vjp  = ventoy_json_path(drive)
                data = None
                if vjp.exists():
                    raw = vjp.read_text(encoding="utf-8", errors="replace").strip()
                    if raw:
                        try:
                            data = json.loads(raw)
                        except json.JSONDecodeError:
                            log("ventoy.json is malformed - resetting to defaults.", "warn")
                if data is None:
                    data = {"persistence": [{"": ""}, {}]}
                pers    = data.setdefault("persistence", [{"": ""}, {}])
                image   = f"/YUMI/{category}/{iso_name}"
                bk_path = f"/YUMI/{category}/{backend}"
                pers    = [p for p in pers if not (
                    isinstance(p, dict) and p.get("image") == image)]
                pers.append({"image": image, "backend": bk_path})
                data["persistence"] = pers
                vjp.parent.mkdir(parents=True, exist_ok=True)
                vjp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
                log(f"Updated ventoy.json", "success")
            except Exception as e:
                log(f"ventoy.json update failed: {e}", "warn")

        except Exception as e:
            log(f"Persistence error: {e}", "warn")

    log("Done.", "success")
    q.put({"type": "done", "rc": 0})


def run_remove(body: dict, q: queue.Queue):
    drive_path     = body.get("drive", "")
    entry          = body.get("entry", "")
    remove_persist = bool(body.get("removePersist", True))

    def log(msg, typ="log"):
        q.put({"type": typ, "text": msg})

    drive = drive_root(drive_path)
    if not drive:
        log(f"Drive not found: {drive_path}", "error")
        q.put({"type": "done", "rc": 1})
        return

    rel    = entry.replace("\\", os.sep)
    target = yumi_root(drive) / rel
    if target.exists():
        target.unlink()
        log(f"Removed {target}", "success")
    else:
        log(f"File not found (may already be removed): {target}", "warn")

    lines = [l for l in read_installed(drive) if l.strip() != entry.strip()]
    write_installed(drive, lines)
    log("Updated Installed.txt", "success")

    parts = entry.replace("/", "\\").split("\\")
    if len(parts) == 2:
        cat, iso = parts
        label       = casper_label(body.get("distroName", ""), iso)
        backend     = backend_filename(iso, label)
        persist     = yumi_root(drive) / cat / backend
        alt_backend = Path(iso).stem + (".rw" if backend.endswith(".persistence") else ".persistence")
        alt_persist = yumi_root(drive) / cat / alt_backend

        removed_backend = None
        for p_path, p_name in [(persist, backend), (alt_persist, alt_backend)]:
            if p_path.exists():
                if remove_persist:
                    p_path.unlink()
                    log(f"Removed sidecar {p_path}", "success")
                    removed_backend = p_name
                else:
                    log(f"Sidecar left on disk: {p_path}", "warn")

        if removed_backend and remove_persist:
            try:
                vjp = ventoy_json_path(drive)
                if vjp.exists():
                    data    = json.loads(vjp.read_text(encoding="utf-8"))
                    image   = f"/YUMI/{cat}/{iso}"
                    bk_path = f"/YUMI/{cat}/{removed_backend}"
                    pers = [p for p in data.get("persistence", [])
                            if not (p.get("image") == image and
                                    p.get("backend") == bk_path)]
                    data["persistence"] = pers or [{"": ""}, {}]
                    vjp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
                    log("Updated ventoy.json", "success")
            except Exception as e:
                log(f"ventoy.json update failed: {e}", "warn")

    q.put({"type": "done", "rc": 0})


def run_prepare(body: dict, q: queue.Queue):
    device     = body.get("device", "")
    mode       = body.get("mode", "install")
    label      = body.get("label", "YUMI")
    nosb       = bool(body.get("nosb", False))
    fs         = body.get("fs", "EXFAT").upper()
    reserve_mb = str(body.get("reserve_mb", "0")).strip()

    def log(msg, typ="log"):
        q.put({"type": typ, "text": msg})

    if IS_WINDOWS:
        if not is_elevated():
            log("Administrator privileges are required for Ventoy install/update.", "error")
            log("Restart as Administrator: right-click yumi_py.exe → Run as administrator.", "warn")
            q.put({"type": "done", "rc": 1})
            return

        TOOLS      = BASE_DIR / "YUMI4Windows" / "tools"
        ventoy_zip = _find_windows_ventoy_zip(TOOLS)
        if not ventoy_zip:
            log(f"No ventoy-*-windows.zip found in {TOOLS}.", "error")
            q.put({"type": "done", "rc": 1})
            return

        tmp_dir = Path(tempfile.mkdtemp(prefix="yumi-ventoy-"))
        try:
            log(f"Extracting {ventoy_zip.name} ...")
            with zipfile.ZipFile(ventoy_zip) as zf:
                zf.extractall(tmp_dir)

            v2d = _find_ventoy2disk(tmp_dir)
            if not v2d:
                log("Ventoy2Disk.exe not found in zip.", "error")
                q.put({"type": "done", "rc": 1})
                return

            drive_letter = device.rstrip("/\\")
            if not drive_letter.endswith(":"):
                drive_letter += ":"

            drive_path = Path(drive_letter + "\\")
            resolved   = resolve_prepare_mode(drive_path, mode)
            if resolved != mode:
                if resolved == "install":
                    log("No existing YUMI/Ventoy detected - using fresh install (/I).", "warn")
                    log("WARNING: Fresh install will repartition the drive and erase data!", "warn")
                else:
                    log("Existing YUMI/Ventoy detected - using update mode (/U).", "log")
                mode = resolved

            vtoy_dir  = v2d.parent
            done_file = vtoy_dir / "cli_done.txt"
            log_file  = vtoy_dir / "cli_log.txt"
            done_file.unlink(missing_ok=True)
            log_file.unlink(missing_ok=True)

            flag      = "/U" if mode == "update" else "/I"
            cmd_parts = [str(v2d), "VTOYCLI", flag,
                         f"/Drive:{drive_letter}", "/NOUSBCheck"]

            # ── Ventoy options - install only (/NOSB, /FS:, /R:) ─────────────
            # On update (/U) Ventoy2Disk ignores partitioning flags; /FS: and
            # /R: require a destructive install to take effect. Match NSIS behaviour:
            # pass no extra args on update.
            if mode != "update":
                if nosb:
                    cmd_parts.append("/NOSB")
                    log("Secure Boot support disabled (/NOSB)")
                if fs and fs != "EXFAT":
                    cmd_parts.append(f"/FS:{fs}")
                    log(f"Storage filesystem: {fs}")
                else:
                    log("Storage filesystem: EXFAT (default)")
                if reserve_mb and reserve_mb != "0":
                    try:
                        int(reserve_mb)
                        cmd_parts.append(f"/R:{reserve_mb}")
                        log(f"Reserving {reserve_mb} MB at end of disk")
                    except ValueError:
                        log(f"Invalid reserve value '{reserve_mb}' - ignored", "warn")
            else:
                log("Update mode - filesystem/reserve options apply to fresh install only.")

            log(f"Running: {' '.join(cmd_parts)}")

            proc = _popen(
                cmd_parts, cwd=str(vtoy_dir),
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1,
            )

            stdout_lines: List[str] = []
            def _reader():
                for ln in proc.stdout:
                    ln = ln.rstrip()
                    if ln:
                        stdout_lines.append(ln)
                        q.put({"type": "log", "text": ln})
            reader = threading.Thread(target=_reader, daemon=True)
            reader.start()

            deadline  = time.time() + 300
            last_size = 0
            while time.time() < deadline:
                time.sleep(1)
                if log_file.exists():
                    try:
                        txt       = log_file.read_text(errors="replace")
                        lines_txt = txt.splitlines()
                        for ln in lines_txt[last_size:]:
                            if ln.strip():
                                q.put({"type": "log", "text": ln})
                        last_size = len(lines_txt)
                    except Exception:
                        pass
                if done_file.exists():
                    time.sleep(0.5)
                    break
                if proc.poll() is not None:
                    time.sleep(0.5)
                    break
            else:
                log("Ventoy timed out after 300 s.", "error")
                proc.kill()
                q.put({"type": "done", "rc": 1})
                return

            reader.join(timeout=5)
            proc.wait()
            rc_proc = proc.returncode

            if done_file.exists():
                try:
                    done_txt = done_file.read_text(errors="replace").strip()
                    rc = int(done_txt) if done_txt.lstrip("-").isdigit() else rc_proc
                except Exception:
                    rc = rc_proc
                done_file.unlink(missing_ok=True)
            else:
                rc = rc_proc

            if rc == 0:
                log(f"Ventoy {'updated' if mode == 'update' else 'installed'} on {drive_letter}.", "success")

                # ── Re-detect drive letter after fresh install ─────────────────
                # Ventoy repartitions the disk on a fresh install (/I), which causes
                # Windows to reassign drive letters (e.g. F: → E:).  The timestamped
                # detail lines go to Ventoy's log FILE, not to stdout, so we must
                # search log_file content rather than stdout_lines.  We also check
                # stdout_lines as a belt-and-suspenders fallback.
                if mode != "update":
                    new_letter = None
                    # Primary: search the log file Ventoy wrote
                    log_sources = list(stdout_lines)
                    if log_file.exists():
                        try:
                            log_sources += log_file.read_text(errors="replace").splitlines()
                        except Exception:
                            pass
                    for ln in log_sources:
                        m = re.search(
                            r"Logical drive letter after write ventoy[:\s]+<([A-Z]+)>",
                            ln, re.IGNORECASE,
                        )
                        if m:
                            # The string is like "EF" - first char is part1 (storage).
                            new_letter = m.group(1)[0].upper() + ":"
                            break
                    # Fallback: ask Windows which letters now live on this physical disk.
                    # After a fresh Ventoy install the original letter (e.g. F:) gets
                    # reassigned to the small EFI part2 (FAT); part1 (the storage
                    # partition) gets a new letter (e.g. E:) and is formatted as the
                    # filesystem chosen by the user (exFAT by default, or NTFS).
                    # We identify part1 by filesystem type: pick the candidate whose
                    # filesystem is NOT FAT / FAT32 (which is always the EFI part2).
                    if new_letter is None or new_letter == drive_letter:
                        try:
                            disk_map = _windows_disk_num_map()  # letter -> disk number
                            orig_disk = disk_map.get(drive_letter[0].upper() + ":", "")
                            if orig_disk:
                                candidates = [
                                    ltr for ltr, dnum in disk_map.items()
                                    if dnum == orig_disk and ltr != drive_letter
                                ]
                                if candidates:
                                    # Query filesystem for each candidate via WMI
                                    def _fstype(ltr: str) -> str:
                                        try:
                                            out = _check_output(
                                                ["wmic", "logicaldisk", "where",
                                                 f"DeviceID='{ltr}'",
                                                 "get", "FileSystem", "/value"],
                                                text=True, errors="replace", timeout=6,
                                            )
                                            for line in out.splitlines():
                                                if line.upper().startswith("FILESYSTEM="):
                                                    return line.split("=", 1)[1].strip().upper()
                                        except Exception:
                                            pass
                                        return ""
                                    # Prefer a candidate that is not FAT/FAT32 (that is part2/EFI)
                                    non_fat = [c for c in candidates
                                               if _fstype(c) not in ("FAT", "FAT32", "")]
                                    new_letter = non_fat[0] if non_fat else candidates[0]
                        except Exception:
                            pass
                    if new_letter and new_letter != drive_letter:
                        log(f"Drive letter reassigned by Ventoy: {drive_letter} → {new_letter}", "warn")
                        drive_letter = new_letter
                        drive_path   = Path(drive_letter + "\\")

                yumi_dest = drive_path / "ventoy"
                yumi_dest.mkdir(exist_ok=True)
                try:
                    # dirs_exist_ok=True requires Python 3.8+ - copy manually for 3.6 compat
                    src_ventoy = str(YUMIFILES / "ventoy")
                    dst_ventoy = str(yumi_dest)
                    for item in Path(src_ventoy).rglob("*"):
                        rel = item.relative_to(src_ventoy)
                        dst_item = Path(dst_ventoy) / rel
                        if item.is_dir():
                            dst_item.mkdir(parents=True, exist_ok=True)
                        else:
                            dst_item.parent.mkdir(parents=True, exist_ok=True)
                            shutil.copy2(str(item), str(dst_item))
                    (drive_path / "YUMI").mkdir(exist_ok=True)
                    log("YUMI theme copied.", "success")
                except Exception as e:
                    log(f"Theme copy warning: {e}", "warn")
                try:
                    _run(
                        ["cmd", "/c", "label", drive_letter, label],
                        capture_output=True, text=True, timeout=15,
                    )
                    log(f"Volume label set to {label}.", "success")
                except Exception as e:
                    log(f"Label warning: {e}", "warn")
            else:
                log(f"Ventoy reported failure (exit code {rc}).", "error")
                if log_file.exists():
                    try:
                        tail = log_file.read_text(errors="replace").strip().splitlines()
                        for ln in tail[-8:]:
                            if ln.strip():
                                log(ln, "error")
                    except Exception:
                        pass
                hint = ("Update failed - the drive may not be prepared yet. Try Prepare Drive first."
                        if mode == "update"
                        else "Try running as Administrator if not already.")
                log(hint, "warn")

            q.put({"type": "done", "rc": rc})
        finally:
            shutil.rmtree(tmp_dir, ignore_errors=True)
        return

    # Linux path
    if not YUMI2DISK.exists():
        log(f"YUMI2Disk.sh not found at {YUMI2DISK}", "error")
        q.put({"type": "done", "rc": 1})
        return

    flag    = "-u" if mode == "update" else "-I"
    qdev    = json.dumps(device)
    qlabel  = json.dumps(label)
    qscript = json.dumps(str(YUMI2DISK))
    qdir    = json.dumps(str(VENTOY_DIR))

    script = f"""\
set -uo pipefail
for PART in {device}[0-9]* {device}p[0-9]* {device}; do
    [ -b "$PART" ] || continue
    MNT=$(lsblk -no MOUNTPOINT "$PART" 2>/dev/null | grep -v '^$' | head -1 || true)
    [ -n "$MNT" ] && umount -l "$PART" 2>/dev/null || true
done
cd {qdir}
echo "Running: YUMI2Disk.sh {flag} -L {label} {device}"
# Skip exfat helper download - pass SKIP_EXFAT=1 so YUMI2Disk.sh won't fetch it
export SKIP_EXFAT=1
printf 'y\\ny\\n' | bash {qscript} {flag} -L {qlabel} {qdev} 2>&1
STATUS=$?
[ $STATUS -eq 0 ] && echo "Ventoy {'updated' if mode == 'update' else 'installed'} on {device}." \\
                  || {{ echo "ERROR: YUMI2Disk.sh exited $STATUS" >&2; exit $STATUS; }}

partprobe {qdev} 2>/dev/null || blockdev --rereadpt {qdev} 2>/dev/null || true
udevadm settle --timeout=5 2>/dev/null || true

PART=""
for CANDIDATE in {qdev}1 {qdev}p1; do
    [ -b "$CANDIDATE" ] && PART="$CANDIDATE" && break
done
[ -z "$PART" ] && PART={qdev}

VDRIVE_MOUNTPOINT=""
_YUMI_DID_MOUNT=0

if [ -b "$PART" ]; then
    # 1. Wait up to 5 s for udev/automounter to mount it
    for _WAIT in 1 2 3 4 5; do
        VDRIVE_MOUNTPOINT=$(lsblk -no MOUNTPOINT "$PART" 2>/dev/null | grep -v '^$' | head -1 || true)
        [ -n "$VDRIVE_MOUNTPOINT" ] && break
        sleep 1
        udevadm settle --timeout=2 2>/dev/null || true
    done

    # 2. Try udisksctl so the desktop automounter owns the mountpoint
    if [ -z "$VDRIVE_MOUNTPOINT" ] && command -v udisksctl >/dev/null 2>&1; then
        _REAL_USER="${{SUDO_USER:-}}"
        _REAL_UID=$(id -u "${{_REAL_USER:-root}}" 2>/dev/null || echo 0)
        _UDISKS_OUT=$(DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$_REAL_UID/bus" \
                      sudo -u "${{_REAL_USER:-root}}" \
                      udisksctl mount --block-device "$PART" --no-user-interaction 2>&1 || true)
        echo "$_UDISKS_OUT"
        VDRIVE_MOUNTPOINT=$(echo "$_UDISKS_OUT" | grep -oP '(?<=at ).*' | head -1 | tr -d '.' || true)
    fi

    # 3. Persistent named mount - stays mounted after script exits so the GUI sees it
    if [ -z "$VDRIVE_MOUNTPOINT" ]; then
        _DEVNAME=$(basename "$PART")
        VDRIVE_MOUNTPOINT="/mnt/yumi-drive-$_DEVNAME"
        mkdir -p "$VDRIVE_MOUNTPOINT"
        if mount "$PART" "$VDRIVE_MOUNTPOINT" 2>/dev/null; then
            echo "Mounted $PART at $VDRIVE_MOUNTPOINT (persistent)"
            _YUMI_DID_MOUNT=1
        else
            echo "Could not mount $PART -- skipping theme copy" >&2
            rmdir "$VDRIVE_MOUNTPOINT" 2>/dev/null || true
            VDRIVE_MOUNTPOINT=""
        fi
    fi
fi

if [ -n "$VDRIVE_MOUNTPOINT" ]; then
    echo "Copying YUMI theme files to $VDRIVE_MOUNTPOINT/ventoy/ ..."
    mkdir -p "$VDRIVE_MOUNTPOINT/ventoy/theme"
    cp -r {json.dumps(str(YUMIFILES))}/ventoy/. "$VDRIVE_MOUNTPOINT/ventoy/" 2>/dev/null || true
    mkdir -p "$VDRIVE_MOUNTPOINT/YUMI"
    echo "YUMI theme copied."
    echo "YUMI_MOUNT=$VDRIVE_MOUNTPOINT"
fi
# Intentionally NOT unmounting - drive stays mounted so the GUI detects it immediately.
"""
    rc = sudo_script(script, q)
    q.put({"type": "done", "rc": rc})


def run_wipe(body: dict, q: queue.Queue):
    device = body.get("device", "")

    def log(msg, typ="log"):
        q.put({"type": typ, "text": msg})

    if IS_WINDOWS:
        drive_letter = device.replace("/", "").replace("\\", "").rstrip()
        if not drive_letter.endswith(":"):
            drive_letter += ":"
        letter_only = drive_letter[0]
        label = body.get("label", "YUMI")
        fs    = body.get("fs", "exFAT")

        disk_num = None
        try:
            out2 = _check_output(
                ["wmic", "logicaldisk", "where",
                 f"DeviceID='{drive_letter}'",
                 "assoc", "/assocclass:Win32_LogicalDiskToPartition"],
                text=True, timeout=8, errors="replace", shell=False)
            m = re.search(r'Disk\s+#?(\d+)', out2, re.IGNORECASE)
            if m:
                disk_num = int(m.group(1))
        except Exception:
            pass

        if disk_num is None:
            disk_num = body.get("diskNum", "")
            if not disk_num:
                log("Could not determine disk number. Pass diskNum in the request or run diskpart manually.", "error")
                q.put({"type": "done", "rc": 1})
                return

        diskpart_lines = [
            f"sel disk {disk_num}",
            "clean",
            "convert mbr",
            "create partition primary",
            "active",
            f"format fs={fs} quick label={label}",
            f"assign letter={letter_only}",
            "exit",
        ]
        fd, tmp_path = tempfile.mkstemp(suffix=".txt", prefix="yumiparts-")
        try:
            with os.fdopen(fd, "w", newline="") as f_:
                f_.write("\r\n".join(diskpart_lines) + "\r\n")
            log(f"Running diskpart on Disk {disk_num} (letter {letter_only}:) ...")
            log("WARNING: ALL DATA ON THE DRIVE WILL BE ERASED.", "warn")
            proc = _popen(
                ["diskpart", "/S", tmp_path],
                stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                text=True, bufsize=1)
            for line in proc.stdout:
                stripped = line.rstrip()
                if stripped:
                    q.put({"type": "log", "text": stripped})
            proc.wait()
            rc = proc.returncode
            if rc == 0:
                log(f"Drive wiped and formatted as {fs}.", "success")
            else:
                log(f"diskpart exited {rc}. Try running as Administrator.", "error")
            q.put({"type": "done", "rc": rc})
        finally:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass
        return

    # Linux path
    if not device:
        log("No device specified.", "error")
        q.put({"type": "done", "rc": 1})
        return

    qdev   = json.dumps(device)
    label  = body.get("label", "YUMI")
    qlabel = json.dumps(label)
    script = f"""\
set -uo pipefail

# ── Unmount all partitions (covers sda1, nvme0n1p1, mmcblk0p1) ───────────────
echo "Unmounting {device} partitions ..."
for PART in {device}[0-9]* {device}p[0-9]*; do
    [ -b "$PART" ] || continue
    umount -l "$PART" 2>/dev/null || true
done
sleep 1

# ── Wipe existing partition table / filesystem signatures ─────────────────────
echo "Wiping partition table on {device} ..."
if command -v wipefs >/dev/null 2>&1; then
    wipefs -a {qdev} 2>&1
else
    dd if=/dev/zero of={qdev} bs=1M count=1 conv=fsync 2>/dev/null || true
    SIZE=$(blockdev --getsize64 {qdev} 2>/dev/null || echo 0)
    if [ "$SIZE" -gt 2097152 ]; then
        dd if=/dev/zero of={qdev} bs=1M count=1 seek=$(( SIZE/1048576 - 1 )) conv=fsync 2>/dev/null || true
    fi
fi

# ── Create new MBR partition table with a single primary partition ────────────
echo "Creating new MBR partition table ..."
if command -v parted >/dev/null 2>&1; then
    parted -s {qdev} mklabel msdos mkpart primary 1MiB 100% 2>&1
elif command -v fdisk >/dev/null 2>&1; then
    printf 'o\\nn\\np\\n1\\n\\n\\nw\\n' | fdisk {qdev} 2>&1
else
    echo "ERROR: Neither parted nor fdisk found." >&2
    exit 1
fi

partprobe {qdev} 2>/dev/null || blockdev --rereadpt {qdev} 2>/dev/null || true
udevadm settle --timeout=10 2>/dev/null || true
sleep 2

# ── Locate the new partition (sda→sda1, nvme0n1→nvme0n1p1, mmcblk0→mmcblk0p1)
PART=""
for CANDIDATE in {device}1 {device}p1; do
    [ -b "$CANDIDATE" ] && PART="$CANDIDATE" && break
done
if [ -z "$PART" ]; then
    echo "ERROR: Could not find partition on {device} after partitioning." >&2
    exit 1
fi
echo "Partition: $PART"

# ── Format as exFAT ──────────────────────────────────────────────────────────
echo "Formatting $PART as exFAT (label: {label}) ..."
if command -v mkfs.exfat >/dev/null 2>&1; then
    mkfs.exfat -n {qlabel} "$PART" 2>&1
elif command -v mkexfatfs >/dev/null 2>&1; then
    mkexfatfs -n {qlabel} "$PART" 2>&1
else
    echo "ERROR: mkfs.exfat not found. Install exfatprogs or exfat-utils:" >&2
    echo "  Ubuntu/Debian: sudo apt install exfatprogs" >&2
    echo "  Fedora:        sudo dnf install exfatprogs" >&2
    echo "  Arch:          sudo pacman -S exfatprogs" >&2
    exit 1
fi

echo "Drive wiped and formatted as exFAT."
"""
    rc = sudo_script(script, q)
    q.put({"type": "done", "rc": rc})


# ── UAC auto-elevation (Windows) - defined at bottom of file ──────────────────


# ── GUI helpers ────────────────────────────────────────────────────────────────

def styled_button(parent, text, command=None, accent=False, danger=False,
                  success=False, small=False, **kw):
    bg  = C["success"] if success else (C["accent"] if accent else (C["danger"] if danger else C["panel2"]))
    fg  = C["bg"] if success or accent or danger else C["text"]
    pad = (6, 3) if small else (12, 6)
    btn = tk.Button(parent, text=text, command=command,
                    bg=bg, fg=fg, activebackground=bg, activeforeground=fg,
                    relief="flat", cursor="hand2",
                    font=UF(9 if small else 10),
                    padx=pad[0], pady=pad[1], bd=0, **kw)
    return btn

def styled_label(parent, text, muted=False, accent=False, **kw):
    color = C["muted"] if muted else (C["accent"] if accent else C["text"])
    return tk.Label(parent, text=text, bg=C["bg"], fg=color,
                    font=UF(10), **kw)

def divider(parent, **kw):
    return tk.Frame(parent, bg=C["border"], height=1, **kw)


# ── Main Application ───────────────────────────────────────────────────────────

class YumiApp(tk.Tk):
    def __init__(self):
        super().__init__(className="YUMI")
        _init_fonts()  # resolve UI/mono font families now that a Tk root exists
        _init_scale(self)  # resolve display scale now that a Tk root exists
        self.title("YUMI - Your Universal Multiboot Installer")
        self.configure(bg=C["bg"])
        # Sidebar holds: drive section (~130px) + drive info (~110px) +
        # prepare button (~60px) + iso/persist/install sections (~160px) +
        # progress panel (~160px) + dividers/padding (~60px) = ~680px min height.
        # Header is 52px. Add a larger buffer so the Add to USB button never
        # gets squeezed against the bottom edge on smaller displays.
        # Base sizes are for a normal-DPI display; scaled() adjusts them for
        # the detected display density (see _detect_scale above).
        self.minsize(scaled(1000), scaled(820))
        self.geometry(f"{scaled(1140)}x{scaled(860)}")

        # State
        self.drives:          List[dict]  = []
        self.selected_drive:  Optional[dict] = None
        self.selected_distro: Optional[dict] = None
        self.iso_path:        str         = ""
        self.search_var       = StringVar()
        self.cat_filter       = StringVar(value="all")
        self.all_drives_var   = BooleanVar(value=False)
        self.persist_var      = BooleanVar(value=False)
        self.persist_mb_var   = IntVar(value=4096)
        self.persist_live_var = StringVar(value="8192")   # for live/Debian zip picker
        self.vhd_size_mb_var  = IntVar(value=30000)       # Windows-On-VHD size
        self.vtoy_nosb_var    = BooleanVar(value=False)
        self.vtoy_fs_var      = StringVar(value="EXFAT")
        self.vtoy_reserve_var = StringVar(value="0")
        self._detail_dl_url   = ""
        self._detail_hp_url   = ""

        # Load icon (used for window icon and header logo)
        self._icon_image = None
        self._icon_thumb = None
        try:
            icon_path = BASE_DIR / "yumi_icon.png"
            if icon_path.exists():
                self._icon_image = PhotoImage(file=str(icon_path))
                self.iconphoto(True, self._icon_image)
                # Scale down for header: 64px / 2 = 32px fits the 36px slot
                # cleanly at normal DPI. scaled_subsample() shrinks the
                # divisor on high-DPI displays so this stays ~32 *physical*
                # pixels instead of shrinking further with the pixel density.
                sub = scaled_subsample(2)
                self._icon_thumb = self._icon_image.subsample(sub, sub)
            # On Windows, iconphoto() alone often fails to update the taskbar /
            # title-bar / Alt-Tab icon (a long-standing Tk-on-Windows quirk) -
            # it only reliably affects the small in-window icon. Always also
            # set iconbitmap from the .ico (which has proper multi-size 32-bit
            # frames) so the exe-equivalent icon shows everywhere, not just as
            # a fallback for when yumi_icon.png is missing.
            if IS_WINDOWS:
                ico_path = BASE_DIR / "yumi.ico"
                if ico_path.exists():
                    self.iconbitmap(default=str(ico_path))
        except Exception:
            pass

        # Pre-render emoji icons from NotoColorEmoji.ttf (Linux only, pure stdlib)
        self._emoji_imgs: dict = self._load_emoji_imgs()

        self._build_ui()
        self.after(100, self.refresh_drives)
        # Silent auto-check at startup - disabled for now (manual "Check for
        # Updates" in the About tab still works). Flip AUTO_CHECK_FOR_UPDATES
        # to True to re-enable it.
        if AUTO_CHECK_FOR_UPDATES:
            self.after(1500, lambda: self.check_for_updates(manual=False))

        self._job_queue: Optional[queue.Queue] = None
        self._poll_job()

    # ── UI Construction ────────────────────────────────────────────────────────

    def _load_emoji_imgs(self) -> dict:
        """
        Extract emoji PNGs directly from NotoColorEmoji.ttf (pure stdlib, no Pillow).
        Returns a dict mapping emoji char -> tk.PhotoImage, or {} if unavailable.
        Only runs on Linux; on Windows emoji render natively in tkinter labels.
        """
        if not IS_LINUX:
            return {}

        import struct
        from pathlib import Path as _Path

        FONT_PATHS = [
            "/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
            "/usr/share/fonts/noto/NotoColorEmoji.ttf",
            "/usr/share/fonts/NotoColorEmoji.ttf",
        ]
        font_path = next((_Path(p) for p in FONT_PATHS if _Path(p).exists()), None)
        if font_path is None:
            return {}

        try:
            data = font_path.read_bytes()

            # Parse table directory
            num_tables = struct.unpack_from('>H', data, 4)[0]
            tables: Dict[str, Tuple[int, int]] = {}
            for i in range(num_tables):
                tag, _, offset, length = struct.unpack_from('>4sIII', data, 12 + i * 16)
                tables[tag.decode('ascii', errors='replace')] = (offset, length)

            if 'CBDT' not in tables or 'CBLC' not in tables or 'cmap' not in tables:
                return {}

            cbdt_off = tables['CBDT'][0]
            cblc_off = tables['CBLC'][0]
            cmap_off = tables['cmap'][0]

            # Build list of cmap subtables
            num_sub = struct.unpack_from('>H', data, cmap_off + 2)[0]
            subtables = []
            for i in range(num_sub):
                pid, eid, off = struct.unpack_from('>HHI', data, cmap_off + 4 + i * 8)
                subtables.append((pid, eid, cmap_off + off))

            def glyph_id(cp: int) -> Optional[int]:
                for _, _, off in subtables:
                    if struct.unpack_from('>H', data, off)[0] == 12:
                        n_groups = struct.unpack_from('>I', data, off + 12)[0]
                        for g in range(n_groups):
                            s, e, sg = struct.unpack_from('>III', data, off + 16 + g * 12)
                            if s <= cp <= e:
                                return sg + (cp - s)
                return None

            # Parse CBLC first bitmap size table
            size_off = cblc_off + 8
            index_tables_off = struct.unpack_from('>I', data, size_off)[0]
            num_index_tables = struct.unpack_from('>I', data, size_off + 8)[0]

            def extract_png(gid: int) -> Optional[bytes]:
                for t in range(num_index_tables):
                    entry_off = size_off + index_tables_off + t * 8
                    first_g, last_g, add_off = struct.unpack_from('>HHI', data, entry_off)
                    if not (first_g <= gid <= last_g):
                        continue
                    sub_off = entry_off + add_off
                    _, _, img_data_off = struct.unpack_from('>HHI', data, sub_off)
                    idx = gid - first_g
                    off_a = struct.unpack_from('>I', data, sub_off + 8 + idx * 4)[0]
                    off_b = struct.unpack_from('>I', data, sub_off + 8 + (idx + 1) * 4)[0]
                    chunk = data[cbdt_off + img_data_off + off_a:
                                 cbdt_off + img_data_off + off_b]
                    png_pos = chunk.find(b'\x89PNG')
                    if png_pos >= 0:
                        return chunk[png_pos:]
                return None

            # Glyphs are 136x128px - subsample(6) → ~22x21px, fits inline
            # icons at normal DPI. scaled_subsample() shrinks the divisor on
            # high-DPI displays so these stay the same physical size instead
            # of shrinking further with the pixel density.
            SUBSAMPLE = scaled_subsample(6)
            result: dict = {}
            emojis = ('🐧', '🐾', '🕵️', '🛡️', '🔧', '💀', '🪟', '🛠️', '💾', '📦', '🔍', '📂')
            for emoji in emojis:
                cp = ord(emoji[0])  # first codepoint handles ZWJ sequences gracefully
                gid = glyph_id(cp)
                if gid is None:
                    continue
                png_bytes = extract_png(gid)
                if png_bytes is None:
                    continue
                try:
                    img = PhotoImage(data=png_bytes)
                    img = img.subsample(SUBSAMPLE, SUBSAMPLE)
                    result[emoji] = img
                except Exception:
                    continue

            return result

        except Exception:
            return {}

    def _build_ui(self):
        header = tk.Frame(self, bg=C["panel"], height=52)
        header.pack(fill="x")
        header.pack_propagate(False)

        logo_frame = tk.Frame(header, bg=C["panel"], cursor="hand2")
        logo_frame.pack(side="left", padx=16, pady=8)
        go_home = lambda e=None: _open_url("https://yumiusb.com")
        if self._icon_thumb:
            logo_icon = tk.Label(logo_frame, image=self._icon_thumb, bg=C["panel"], cursor="hand2")
        else:
            disk_img = self._emoji_imgs.get("💾") if IS_LINUX else None
            if disk_img:
                logo_icon = tk.Label(logo_frame, image=disk_img, bg=C["panel"], cursor="hand2")
                logo_icon.image = disk_img
            else:
                logo_icon = tk.Label(logo_frame, text="💾" if IS_WINDOWS else "■",
                                     bg=C["panel"], font=_uf(18), cursor="hand2")
        logo_icon.pack(side="left", padx=(0, 4))
        title_lbl = tk.Label(logo_frame, text="YUMI", bg=C["panel"], fg=C["yumi"],
                              font=UF(16, "bold"), cursor="hand2")
        title_lbl.pack(side="left")
        subtitle_lbl = tk.Label(logo_frame, text=" Your Universal Multiboot Installer",
                                 bg=C["panel"], fg=C["muted"], font=UF(10), cursor="hand2")
        subtitle_lbl.pack(side="left")
        for w in (logo_frame, logo_icon, title_lbl, subtitle_lbl):
            w.bind("<Button-1>", go_home)

        nav = tk.Frame(header, bg=C["panel"])
        nav.pack(side="right", padx=12)

        self.tab_btns: Dict[str, tk.Button] = {}
        for tab, lbl in [("create", "Create"), ("addqueue", "Add Multiple"),
                          ("installed", "Installed"), ("about", "About")]:
            btn = tk.Button(nav, text=lbl, bg=C["panel"], fg=C["muted"],
                            activebackground=C["panel2"], activeforeground=C["text"],
                            relief="flat", bd=0, cursor="hand2",
                            font=UF(10), padx=12, pady=6,
                            command=lambda t=tab: self.switch_tab(t))
            btn.pack(side="left")
            self.tab_btns[tab] = btn

        divider(self).pack(fill="x")

        body = tk.Frame(self, bg=C["bg"])
        body.pack(fill="both", expand=True)

        self.sidebar = tk.Frame(body, bg=C["panel"], width=260)
        self.sidebar.pack(side="left", fill="y")
        self.sidebar.pack_propagate(False)
        self._build_sidebar()

        divider(body).pack(side="left", fill="y")

        self.main_area = tk.Frame(body, bg=C["bg"])
        self.main_area.pack(side="left", fill="both", expand=True)

        self.panels: Dict[str, tk.Frame] = {}
        for tab in ("create", "addqueue", "installed", "about"):
            f = tk.Frame(self.main_area, bg=C["bg"])
            self.panels[tab] = f

        self._build_create_panel()
        self._build_addqueue_panel()
        self._build_installed_panel()
        self._build_about_panel()
        self.switch_tab("create")

    def _build_sidebar(self):
        sb = self.sidebar

        sec = tk.Frame(sb, bg=C["panel"])
        sec.pack(fill="x", padx=14, pady=(12, 6))
        tk.Label(sec, text="USB / Target Drive", bg=C["panel"], fg=C["muted"],
                 font=UF(8, "bold")).pack(anchor="w")

        row = tk.Frame(sec, bg=C["panel"])
        row.pack(fill="x", pady=(4, 0))
        self.drive_combo = ttk.Combobox(row, state="readonly", font=UF(9))
        self.drive_combo.pack(side="left", fill="x", expand=True)
        self.drive_combo.bind("<<ComboboxSelected>>", self.on_drive_change)
        styled_button(row, "↺", self.refresh_drives, small=True).pack(side="left", padx=(4, 0))

        tog_row = tk.Frame(sec, bg=C["panel"])
        tog_row.pack(fill="x", pady=(6, 0))
        tk.Checkbutton(tog_row, text="Show all drives", variable=self.all_drives_var,
                       bg=C["panel"], fg=C["muted"], activebackground=C["panel"],
                       selectcolor=C["panel"], font=UF(9),
                       bd=0, highlightthickness=0,
                       command=self.refresh_drives).pack(side="left")

        divider(sb).pack(fill="x", pady=8)

        info = tk.Frame(sb, bg=C["panel"])
        info.pack(fill="x", padx=14)
        for attr in ("Label", "Filesystem", "Size", "Status"):
            row2 = tk.Frame(info, bg=C["panel"])
            row2.pack(fill="x", pady=0)
            tk.Label(row2, text=attr, bg=C["panel"], fg=C["muted"],
                     font=UF(8), width=9, anchor="w").pack(side="left")
            var = StringVar(value="-")
            tk.Label(row2, textvariable=var, bg=C["panel"], fg=C["text"],
                     font=UF(8), anchor="w").pack(side="left", fill="x", expand=True)
            setattr(self, f"drive_{attr.lower()}_var", var)

        bar_frame = tk.Frame(sb, bg=C["panel"])
        bar_frame.pack(fill="x", padx=14, pady=(4, 0))
        bar_bg = tk.Frame(bar_frame, bg=C["border"], height=4)
        bar_bg.pack(side="left", fill="x", expand=True, padx=(0, 6))
        self.drive_bar = tk.Frame(bar_bg, bg=C["accent"], height=4)
        self.drive_bar.place(x=0, y=0, relheight=1, relwidth=0)
        self.drive_pct_var = StringVar(value="0%")
        tk.Label(bar_frame, textvariable=self.drive_pct_var, bg=C["panel"],
                 fg=C["muted"], font=UF(8)).pack(side="left")

        divider(sb).pack(fill="x", pady=(10, 6))

        prep = tk.Frame(sb, bg=C["panel"])
        prep.pack(fill="x", padx=14, pady=(0, 6))
        self.prep_frame = prep

        prep_row = tk.Frame(prep, bg=C["panel"])
        prep_row.pack(fill="x")
        self.prep_chk = tk.Button(prep_row, text="Prepare Drive",
                                      bg=C["panel"], fg=C["danger"],
                                      activebackground=C["panel"], activeforeground=C["warn"],
                                      relief="flat", bd=0, highlightthickness=0, cursor="hand2",
                                      font=UF(9, "bold"), padx=0, pady=2,
                                      command=self.on_prepare_click)
        self.prep_chk.pack(side="left", anchor="w")

        tk.Label(prep_row, text="  |  ", bg=C["panel"], fg=C["border"],
                 font=UF(9)).pack(side="left")

        self.wipe_btn = tk.Button(prep_row, text="Wipe & Format",
                                      bg=C["panel"], fg=C["muted"],
                                      activebackground=C["panel"], activeforeground=C["danger"],
                                      relief="flat", bd=0, highlightthickness=0, cursor="hand2",
                                      font=UF(9), padx=0, pady=2,
                                      command=self.on_wipe_click)
        self.wipe_btn.pack(side="left", anchor="w")
        self.prep_hint = tk.Label(prep, text="", bg=C["panel"], fg=C["muted"],
                                  font=UF(8), wraplength=220, justify="left")
        self.prep_hint.pack(anchor="w", pady=(1, 0))

        divider(sb).pack(fill="x", pady=(6, 0))

        # ── (1) Browse for ISO - hidden until distro selected ────────────────
        self.iso_sec = tk.Frame(sb, bg=C["panel"])
        # not packed yet - shown in on_distro_select
        self.iso_label = tk.Label(self.iso_sec, text="No file selected", bg=C["panel"],
                                  fg=C["muted"], font=UF(8),
                                  wraplength=220, justify="left")
        self.iso_label.pack(anchor="w", pady=(0, 4))
        self.browse_btn = styled_button(self.iso_sec, "Browse for ISO…", self.browse_iso, accent=True)
        self.browse_btn.pack(fill="x")

        # ── (2) Create Persistence - hidden until distro selected + supports it
        self.persist_sec = tk.Frame(sb, bg=C["panel"])
        # not packed yet
        self.persist_chk = tk.Checkbutton(self.persist_sec, text="Create persistence",
                                          variable=self.persist_var,
                                          bg=C["panel"], fg=C["text"],
                                          activebackground=C["panel"],
                                          selectcolor=C["panel"], font=UF(9),
                                          bd=0, highlightthickness=0,
                                          command=self._update_persist_row)
        self.persist_chk.pack(anchor="w")
        self.persist_row = tk.Frame(self.persist_sec, bg=C["panel"])
        self.persist_row.pack(fill="x", pady=(4, 0))

        # ── Continuous slider (casper / writable / vtoycow) ───────────────────
        self.persist_slider_frame = tk.Frame(self.persist_row, bg=C["panel"])
        self.persist_slider_frame.pack(fill="x")
        self.persist_size_lbl = tk.Label(self.persist_slider_frame, text="Size: 4096 MB (4 GB)",
                                         bg=C["panel"], fg=C["muted"], font=UF(9))
        self.persist_size_lbl.pack(anchor="w")
        self.persist_slider = tk.Scale(
            self.persist_slider_frame,
            variable=self.persist_mb_var,
            from_=512, to=32768,
            resolution=512,
            orient="horizontal",
            bg=C["panel"], fg=C["text"],
            troughcolor=C["panel2"],
            activebackground=C["accent2"],
            highlightcolor=C["accent2"],
            highlightbackground=C["border"],
            highlightthickness=0,
            showvalue=False,
            command=self._on_persist_slider,
        )
        self.persist_slider.pack(fill="x", pady=(2, 0))

        # ── Fixed size picker (live/Debian zip method) ────────────────────────
        self.persist_live_frame = tk.Frame(self.persist_row, bg=C["panel"])
        tk.Label(self.persist_live_frame, text="Persistence size:",
                 bg=C["panel"], fg=C["muted"], font=UF(9)).pack(anchor="w")
        for label, val in (("8 GB", "8192"), ("16 GB", "16384"), ("32 GB", "32768")):
            tk.Radiobutton(
                self.persist_live_frame, text=label,
                variable=self.persist_live_var, value=val,
                bg=C["panel"], fg=C["text"], activebackground=C["panel"],
                selectcolor=C["panel"], font=UF(9),
                bd=0, highlightthickness=0,
                command=self._update_persist_row,
            ).pack(anchor="w", padx=(8, 0))
        # persist_live_frame packed/unpacked in _update_persist_row

        # ── VHD size (shown only for Windows-On-VHD distros) ─────────────────
        self.vhd_sec = tk.Frame(sb, bg=C["panel"])
        # not packed yet - shown/hidden in on_distro_select
        tk.Label(self.vhd_sec, text="VHD size (MB):",
                 bg=C["panel"], fg=C["muted"], font=UF(9)).pack(anchor="w")
        vhd_row = tk.Frame(self.vhd_sec, bg=C["panel"])
        vhd_row.pack(fill="x", pady=(2, 0))
        self.vhd_size_lbl = tk.Label(vhd_row, text="30000 MB (~30 GB)",
                                     bg=C["panel"], fg=C["muted"],
                                     font=UF(9))
        self.vhd_size_lbl.pack(anchor="w")
        self.vhd_slider = tk.Scale(
            self.vhd_sec,
            variable=self.vhd_size_mb_var,
            from_=10000, to=128000,
            resolution=1000,
            orient="horizontal",
            bg=C["panel"], fg=C["text"],
            troughcolor=C["panel2"],
            activebackground=C["accent2"],
            highlightcolor=C["accent2"],
            highlightbackground=C["border"],
            highlightthickness=0,
            showvalue=False,
            command=self._on_vhd_slider,
        )
        self.vhd_slider.pack(fill="x", pady=(2, 0))

        # ── Ventoy prep options (shown permanently in sidebar) ─────────────────
        divider(sb).pack(fill="x", pady=(6, 6))
        vtoy_opts = tk.Frame(sb, bg=C["panel"])
        vtoy_opts.pack(fill="x", padx=14, pady=(0, 4))
        tk.Label(vtoy_opts, text="Drive Options", bg=C["panel"], fg=C["muted"],
                 font=UF(8, "bold")).pack(anchor="w", pady=(0, 3))

        # Disable Secure Boot
        self.vtoy_nosb_chk = tk.Checkbutton(
            vtoy_opts, text="Disable Secure Boot support",
            variable=self.vtoy_nosb_var,
            bg=C["panel"], fg=C["text"], activebackground=C["panel"],
            selectcolor=C["panel"], font=UF(8),
            bd=0, highlightthickness=0,
        )
        self.vtoy_nosb_chk.pack(anchor="w")

        # Storage filesystem
        fs_row = tk.Frame(vtoy_opts, bg=C["panel"])
        fs_row.pack(fill="x", pady=(3, 0))
        tk.Label(fs_row, text="Storage filesystem:", bg=C["panel"], fg=C["muted"],
                 font=UF(8)).pack(side="left")
        self.vtoy_fs_menu = tk.OptionMenu(fs_row, self.vtoy_fs_var, "EXFAT", "NTFS", "FAT32")
        self.vtoy_fs_menu.configure(bg=C["panel2"], fg=C["text"], activebackground=C["panel2"],
                          activeforeground=C["text"], relief="flat",
                          highlightthickness=0, font=UF(8), bd=0)
        self.vtoy_fs_menu["menu"].configure(
            bg=C["panel2"], fg=C["text"],
            activebackground=C["accent"], activeforeground="#ffffff",
            font=UF(8),
        )
        self.vtoy_fs_menu.pack(side="left", padx=(4, 0))

        # Reserve space
        res_row = tk.Frame(vtoy_opts, bg=C["panel"])
        res_row.pack(fill="x", pady=(3, 0))
        tk.Label(res_row, text="Reserve space (MB):", bg=C["panel"], fg=C["muted"],
                 font=UF(8)).pack(side="left")
        vcmd = (self.register(lambda s: s.isdigit() or s == ""), '%P')
        self.vtoy_reserve_entry = tk.Entry(
            res_row, textvariable=self.vtoy_reserve_var,
            width=6, bg=C["panel2"], fg=C["text"],
            insertbackground=C["text"], relief="flat",
            font=UF(8), validate="key", validatecommand=vcmd,
        )
        self.vtoy_reserve_entry.pack(side="left", padx=(4, 0))

        # ── (3) Add to USB Drive - hidden until ISO is selected ───────────────
        self.install_sec = tk.Frame(sb, bg=C["panel"])
        # not packed yet
        self.install_btn = styled_button(self.install_sec, "⚡  Add to USB Drive",
                                         self.do_install, success=True)
        self.install_btn.pack(fill="x")

        # ── Progress panel - always shown at bottom of sidebar ────────────────
        self.progress_sec = tk.Frame(sb, bg=C["panel"])
        self.progress_sec.pack(fill="x", padx=14, pady=(10, 8), side="bottom")
        divider(sb).pack(fill="x", side="bottom")
        self._prog_title_lbl = tk.Label(self.progress_sec, text="Progress",
                                        bg=C["panel"], fg=C["muted"],
                                        font=UF(9, "bold"))
        self._prog_title_lbl.pack(anchor="w", padx=2, pady=(4, 2))
        self._prog_pct_lbl = tk.Label(self.progress_sec, text="",
                                      bg=C["panel"], fg=C["muted"],
                                      font=UF(8))
        self._prog_pct_lbl.pack(anchor="w", padx=2)
        pbar_frame = tk.Frame(self.progress_sec, bg=C["border"], height=5)
        pbar_frame.pack(fill="x", pady=(2, 4))
        self._prog_bar = tk.Frame(pbar_frame, bg=C["accent"], height=5)
        self._prog_bar.place(x=0, y=0, relheight=1, relwidth=0)
        self._prog_log = tk.Text(self.progress_sec, bg=C["bg"], fg=C["text"],
                                 relief="flat", bd=0, font=MF(8),
                                 height=6, state="disabled",
                                 wrap="word")
        self._prog_log.pack(fill="x", pady=(0, 4))
        self._prog_log.tag_configure("error",   foreground=C["danger"])
        self._prog_log.tag_configure("success", foreground=C["success"])
        self._prog_log.tag_configure("warn",    foreground=C["warn"])
        self._prog_log.tag_configure("log",     foreground=C["text"])

    def _build_create_panel(self):
        f = self.panels["create"]

        bar = tk.Frame(f, bg=C["panel"])
        bar.pack(fill="x", padx=16, pady=(12, 0))
        # Search icon - draw a simple magnifier on a Canvas rather than
        # relying on emoji rendering which varies by font/platform
        icon_size = 20
        icon_canvas = tk.Canvas(bar, width=icon_size, height=icon_size,
                                bg=C["panel"], highlightthickness=0)
        icon_canvas.pack(side="left", padx=(0, 6))
        # Circle
        m = 3
        icon_canvas.create_oval(m, m, icon_size - 7, icon_size - 7,
                                outline=C["muted"], width=2)
        # Handle
        icon_canvas.create_line(icon_size - 8, icon_size - 8,
                                icon_size - 2, icon_size - 2,
                                fill=C["muted"], width=2)
        search_entry = tk.Entry(bar, textvariable=self.search_var,
                                bg=C["entry_bg"], fg=C["text"],
                                insertbackground=C["text"], relief="flat",
                                font=UF(10), bd=4)
        search_entry.pack(side="left", fill="x", expand=True)
        search_entry.insert(0, "Search distros…")
        search_entry.bind("<FocusIn>", lambda e: search_entry.delete(0, END)
                          if search_entry.get() == "Search distros…" else None)
        self.search_var.trace_add("write", lambda *a: self.render_distro_list())

        cat_bar = tk.Frame(f, bg=C["bg"])
        cat_bar.pack(fill="x", padx=16, pady=(8, 0))
        self._cat_btns: Dict[str, tk.Button] = {}
        all_btn = tk.Button(cat_bar, text="All",
                            bg=C["accent"], fg=C["bg"],
                            activebackground=C["accent2"], activeforeground=C["bg"],
                            relief="flat", bd=0, cursor="hand2",
                            font=UF(9), padx=10, pady=4,
                            command=lambda: self.set_cat("all"))
        all_btn.pack(side="left", padx=(0, 4))
        self._cat_btns["all"] = all_btn

        for cat_id, cat_info in CATEGORIES.items():
            if IS_LINUX and cat_id == "Windows-On-VHD":
                continue  # VHD installs require Windows - hide on Linux
            icon  = CAT_ICONS.get(cat_id, "•")
            lbl   = cat_info.get("label", cat_id)
            btn   = tk.Button(cat_bar, text=f"{icon} {lbl}",
                              bg=C["panel2"], fg=C["muted"],
                              activebackground=C["border"], activeforeground=C["text"],
                              relief="flat", bd=0, cursor="hand2",
                              font=UF(9), padx=8, pady=4,
                              command=lambda c=cat_id: self.set_cat(c))
            btn.pack(side="left", padx=(0, 4))
            self._cat_btns[cat_id] = btn

        divider(f).pack(fill="x", pady=(8, 0))

        list_frame = tk.Frame(f, bg=C["bg"])
        list_frame.pack(fill="both", expand=True, padx=16, pady=8)
        scrollbar = tk.Scrollbar(list_frame, bg=C["panel"])
        scrollbar.pack(side="right", fill="y")
        self.distro_listbox = tk.Listbox(list_frame,
                                         bg=C["bg"], fg=C["text"],
                                         selectbackground=C["panel2"],
                                         selectforeground=C["accent"],
                                         activestyle="none",
                                         relief="flat", bd=0,
                                         font=UF(10),
                                         yscrollcommand=scrollbar.set)
        self.distro_listbox.pack(fill="both", expand=True)
        scrollbar.config(command=self.distro_listbox.yview)
        self.distro_listbox.bind("<<ListboxSelect>>", self.on_distro_select)
        self.distro_listbox.bind("<Double-Button-1>", self.on_distro_open_url)
        self._distro_items: List[dict] = []
        self.render_distro_list()

        detail = tk.Frame(f, bg=C["panel"])
        detail.pack(fill="x", padx=16, pady=(0, 8))

        # ── Name row: colour swatch + icon + name ─────────────────────────────
        name_row = tk.Frame(detail, bg=C["panel"])
        name_row.pack(fill="x", padx=10, pady=(8, 0))
        # Category colour swatch (3px wide bar)
        self.detail_swatch = tk.Frame(name_row, bg=C["muted"], width=3)
        self.detail_swatch.pack(side="left", fill="y", padx=(0, 8))
        # Category icon
        self.detail_cat_icon = tk.Label(name_row, text="", bg=C["panel"], fg=C["muted"],
                                        font=UF(14))
        self.detail_cat_icon.pack(side="left", padx=(0, 6))
        # Distro name
        self.detail_name = tk.Label(name_row, text="Select a distro above",
                                    bg=C["panel"], fg=C["text"],
                                    font=UF(11, "bold"))
        self.detail_name.pack(side="left", anchor="w")

        # ── Meta row: category pill + pattern ────────────────────────────────
        meta_row = tk.Frame(detail, bg=C["panel"])
        meta_row.pack(fill="x", padx=10, pady=(3, 0))
        self.detail_cat = tk.Label(meta_row, text="", bg=C["panel"], fg=C["muted"],
                                   font=UF(8))
        self.detail_cat.pack(side="left")
        self.detail_pattern = tk.Label(meta_row, text="", bg=C["panel"], fg=C["muted"],
                                       font=UF(8))
        self.detail_pattern.pack(side="left", padx=(10, 0))

        # ── Links row: Homepage · Download ────────────────────────────────────
        links_row = tk.Frame(detail, bg=C["panel"])
        links_row.pack(fill="x", padx=10, pady=(4, 0))
        self._detail_dl_url: str = ""
        self._detail_hp_url: str = ""
        self.detail_hp = tk.Button(
            links_row, text="", bg=C["panel"], fg=C["muted"],
            activebackground=C["panel"], activeforeground=C["text"],
            relief="flat", bd=0, highlightthickness=0, cursor="hand2",
            font=UF(9),
            command=self.on_open_homepage,
        )
        self.detail_hp.pack(side="left")
        # Separator dot - only shown when both links present
        self.detail_link_sep = tk.Label(links_row, text="", bg=C["panel"], fg=C["muted"],
                                        font=UF(9))
        self.detail_link_sep.pack(side="left")
        self.detail_dl = tk.Button(
            links_row, text="", bg=C["panel"], fg=C["accent"],
            activebackground=C["panel"], activeforeground=C["accent2"],
            relief="flat", bd=0, highlightthickness=0, cursor="hand2",
            font=UF(9),
            command=self.on_open_download,
        )
        self.detail_dl.pack(side="left")

        # ── Description - empty for now, shown when populated ─────────────────
        self.detail_desc = tk.Label(detail, text="", bg=C["panel"], fg=C["muted"],
                                    font=UF(9), wraplength=560,
                                    justify="left", anchor="w")
        self.detail_desc.pack(fill="x", padx=10, pady=(4, 8))

    def _build_installed_panel(self):
        f = self.panels["installed"]
        hdr = tk.Frame(f, bg=C["bg"])
        hdr.pack(fill="x", padx=16, pady=(12, 0))
        tk.Label(hdr, text="Installed on drive", bg=C["bg"], fg=C["text"],
                 font=UF(12, "bold")).pack(side="left")
        styled_button(hdr, "↺ Refresh", self.refresh_installed, small=True).pack(side="right")
        divider(f).pack(fill="x", pady=(8, 0))
        self.installed_frame = tk.Frame(f, bg=C["bg"])
        self.installed_frame.pack(fill="both", expand=True, padx=16, pady=8)
        tk.Label(self.installed_frame, text="Select a drive to see installed ISOs.",
                 bg=C["bg"], fg=C["muted"], font=UF(10)).pack(pady=30)

    def _build_about_panel(self):
        f = self.panels["about"]
        outer = tk.Frame(f, bg=C["bg"])
        outer.pack(fill="both", expand=True, padx=40, pady=30)

        def lbl(text, bold=False, muted=False, size=10, pady=(0, 0)):
            tk.Label(outer, text=text, bg=C["bg"],
                     fg=C["muted"] if muted else C["text"],
                     font=UF(size, "bold" if bold else "normal"),
                     justify="left", anchor="w",
                     wraplength=560).pack(fill="x", pady=pady)

        lbl("YUMI - Your Universal Multiboot Installer", bold=True, size=13, pady=(0, 6))

        lbl("YUMI is a tool for creating multiboot USB drives. It provides popular "
            "Linux distribution info, including homepages and download links. "
            "The tool allows for persistent file creation for saving changes and "
            "restoring them on subsequent boots where applicable. ISOs are organised "
            "into pre-created categories on the drive.", muted=True, pady=(0, 10))

        lbl("This standalone Python GUI is a direct port of the YUMI exFAT "
            "application. Usage is simple:", muted=True, pady=(0, 4))

        for step in (
            "1.  Select a USB drive from the sidebar",
            "2.  Choose a distro from the list",
            "3.  Browse to the ISO on your computer\n      (optionally set a persistence file size)",
            '4.  Click "Add to USB Drive"',
        ):
            tk.Label(outer, text=step, bg=C["bg"], fg=C["text"],
                     font=UF(10), justify="left", anchor="w",
                     wraplength=560).pack(fill="x", pady=(0, 1))

        # Admin / root instructions - platform specific
        tk.Frame(outer, bg=C["border"], height=1).pack(fill="x", pady=(12, 10))

        lbl("Running as Administrator / root is required for:", bold=False, muted=True)

        if IS_WINDOWS:
            admin_steps = (
                "• Preparing (installing/updating) the Ventoy bootloader on the drive",
                "• Installing distros and creating persistence files",
            )
            how = ("Right-click yumi_py.exe → Run as administrator, "
                   "or the app will prompt for UAC elevation automatically.")
        else:
            admin_steps = (
                "• Preparing (installing/updating) the Ventoy bootloader on the drive",
                "• Mounting drives and creating persistence files",
            )
            how = (
                "The app will attempt to elevate automatically using sudo or pkexec.\n\n"
                "If prompted, enter your password in the terminal.\n\n"
                "To avoid repeated prompts, launch directly as root:\n"
                "    sudo ./yumi_py"
            )

        for step in admin_steps:
            tk.Label(outer, text=step, bg=C["bg"], fg=C["muted"],
                     font=UF(9), justify="left", anchor="w",
                     wraplength=560).pack(fill="x")

        tk.Label(outer, text=how, bg=C["bg"], fg=C["text"],
                 font=UF(9, "italic"), justify="left", anchor="w",
                 wraplength=560).pack(fill="x", pady=(4, 0))

        # Clickable source link
        tk.Frame(outer, bg=C["border"], height=1).pack(fill="x", pady=(12, 8))
        link_row = tk.Frame(outer, bg=C["bg"])
        link_row.pack(fill="x")
        tk.Label(link_row, text="Source: ", bg=C["bg"], fg=C["muted"],
                 font=UF(9)).pack(side="left")
        link = tk.Button(
            link_row, text="yumiusb.com",
            bg=C["bg"], fg=C["accent"],
            activebackground=C["bg"], activeforeground=C["accent2"],
            relief="flat", bd=0, highlightthickness=0, cursor="hand2",
            font=UF(9, "underline"),
            command=lambda: _open_url("https://yumiusb.com"),
        )
        link.pack(side="left")

        # Version / update check
        tk.Frame(outer, bg=C["border"], height=1).pack(fill="x", pady=(12, 8))
        ver_row = tk.Frame(outer, bg=C["bg"])
        ver_row.pack(fill="x")
        tk.Label(ver_row, text=f"Version {APP_VERSION}", bg=C["bg"], fg=C["muted"],
                 font=UF(9)).pack(side="left")
        self._update_check_btn = styled_button(
            ver_row, "Check for Updates",
            command=lambda: self.check_for_updates(manual=True),
            small=True,
        )
        self._update_check_btn.pack(side="left", padx=(10, 0))

    # ── Theme switching ────────────────────────────────────────────────────────

    # ── Tab switching ──────────────────────────────────────────────────────────

    def switch_tab(self, tab: str):
        for t, btn in self.tab_btns.items():
            btn.configure(bg=C["panel2"] if t == tab else C["panel"],
                          fg=C["accent"] if t == tab else C["muted"])
        for t, panel in self.panels.items():
            if t == tab:
                panel.pack(fill="both", expand=True)
            else:
                panel.pack_forget()
        if tab == "installed":
            self.refresh_installed()

    # ── Drive handling ─────────────────────────────────────────────────────────

    @staticmethod
    def _format_drive_label(d: dict) -> str:
        """
        Build a combobox label that identifies the physical disk the way
        classic YUMI's installer did: drive letter(s) + "(Disk N)" on
        Windows, /dev/sdX (+ partition) on Linux. Falls back gracefully
        if disk_num / device info wasn't resolvable.
        """
        name = (d.get("label") or "?").strip()
        fs   = d.get("fs", "?") or "?"
        size = d.get("total_gb", 0) or 0

        if IS_WINDOWS:
            letters = d.get("letters") or [(d.get("device") or d.get("path", "")).rstrip("\\")]
            letter_str = " ".join(l for l in letters if l) or "?"
            disk_num = d.get("disk_num", "")
            ident = f"{letter_str} (Disk {disk_num})" if disk_num else letter_str
        else:
            dev  = d.get("device", "")            # e.g. /dev/sdb
            part = d.get("partition", "")          # e.g. /dev/sdb1
            ident = part or dev or "?"

        return f"{ident}  {name}  ({fs}, {size:.1f}GB)"

    def refresh_drives(self):
        # Remember which device was selected so we can restore it after refresh
        prev_device = ""
        if self.selected_drive:
            prev_device = (self.selected_drive.get("device") or
                           self.selected_drive.get("partition") or
                           self.selected_drive.get("path", ""))

        self.drives = list_drives(self.all_drives_var.get())
        valid  = [d for d in self.drives if "error" not in d]
        labels = [self._format_drive_label(d) for d in valid]
        self.drive_combo["values"] = labels

        if not labels:
            self.drive_combo.set("No drives found")
            self.selected_drive = None
            self._update_drive_info(None)
            return

        # Re-select the previously chosen drive by matching device path
        restore_idx = 0
        if prev_device:
            for i, d in enumerate(valid):
                if (d.get("device") == prev_device or
                        d.get("partition") == prev_device or
                        d.get("path") == prev_device):
                    restore_idx = i
                    break

        self.drive_combo.current(restore_idx)
        self.on_drive_change()

    def _refresh_drive_data(self, d: dict):
        """
        Mount the drive ONCE, gather both status fields and the installed-ISO
        list, then unmount. Storing the installed list on d["_installed"]
        means refresh_installed() can just render from it instead of doing
        its own separate mount - switching drives in quick succession used to
        chain multiple mount/unmount cycles on the same device, which on
        Linux (where mounting goes through udisksctl/automount) could race
        and momentarily show data left over from the previous drive.
        """
        root_path = d.get("path") or d.get("device", "")
        root = drive_root(root_path, readonly=True) if root_path else None
        if not root:
            d["_installed"] = None  # sentinel: drive not accessible
            return
        try:
            d.update(drive_status(root))
            used_gb, total_gb = _disk_usage(str(root))
            d["used_gb"]    = used_gb
            d["total_gb"]   = total_gb
            d["path"]       = str(root)
            d["_installed"] = scan_installed_details(root)
        finally:
            drive_root_cleanup(root)

    def on_drive_change(self, event=None):
        idx   = self.drive_combo.current()
        valid = [d for d in self.drives if "error" not in d]
        if 0 <= idx < len(valid):
            d = valid[idx]
            # Re-read drive status from disk so the Prepare button is accurate
            # (after a fresh Ventoy install the ventoy folder may have just appeared)
            self._refresh_drive_data(d)
            self.selected_drive = d
        else:
            self.selected_drive = None
        self._update_drive_info(self.selected_drive)
        self.refresh_installed()

    def _update_drive_info(self, d: Optional[dict]):
        if d is None:
            for attr in ("label", "filesystem", "size", "status"):
                getattr(self, f"drive_{attr}_var").set("-")
            self.drive_bar.place(relwidth=0)
            self.drive_pct_var.set("0%")
            self.prep_chk.configure(text="Prepare Drive", fg=C["muted"])
            self.prep_hint.configure(text="")
            return

        self.drive_label_var.set(d.get("label", "-"))
        self.drive_filesystem_var.set(d.get("fs", "-"))
        total = d.get("total_gb", 0)
        used  = d.get("used_gb", 0)
        self.drive_size_var.set(f"{used:.1f} / {total:.1f} GB" if total else "-")

        if d.get("prepared"):
            self.drive_status_var.set("Ready")
        elif d.get("has_ventoy"):
            self.drive_status_var.set("Ventoy (no YUMI) - needs prepare")
        elif d.get("has_yumi"):
            self.drive_status_var.set("YUMI (no Ventoy) - needs prepare")
        else:
            self.drive_status_var.set("Not prepared")

        pct = (used / total) if total else 0
        self.drive_bar.place(relwidth=min(pct, 1.0))
        self.drive_pct_var.set(f"{int(pct*100)}%")

        if d.get("prepared"):
            self.prep_chk.configure(text="Update", fg=C["warn"])
            self.prep_hint.configure(text="Update bootloader and YUMI theme on this drive.")
            # Filesystem/reserve options only apply on fresh install - grey them out
            _vtoy_opt_state = "disabled"
            self.prep_hint.configure(text="Update bootloader and YUMI theme on this drive.\n"
                     "Note: filesystem/reserve options apply to fresh installs only.")
        else:
            self.prep_chk.configure(text="Prepare Drive", fg=C["danger"])
            self.prep_hint.configure(text="Prepare drive for YUMI. Will erase all data on the drive!")
            _vtoy_opt_state = "normal"

        # Enable/disable ventoy install-only options accordingly
        self.vtoy_nosb_chk.configure(state=_vtoy_opt_state)
        self.vtoy_fs_menu.configure(state=_vtoy_opt_state)
        self.vtoy_reserve_entry.configure(state=_vtoy_opt_state)


    # ── Distro list ────────────────────────────────────────────────────────────

    def set_cat(self, cat_id: str):
        self.cat_filter.set(cat_id)
        for cid, btn in self._cat_btns.items():
            if cid == cat_id:
                btn.configure(bg=C["accent"], fg=C["bg"])
            else:
                btn.configure(bg=C["panel2"], fg=C["muted"])
        self.render_distro_list()

    def render_distro_list(self):
        query = self.search_var.get().lower().strip()
        if query == "search distros…":
            query = ""
        cat = self.cat_filter.get()
        self._distro_items = []
        for d in DISTROS:
            if cat != "all" and d.get("cat") != cat:
                continue
            if query and query not in d.get("name", "").lower():
                continue
            self._distro_items.append(d)
        self.distro_listbox.delete(0, END)
        for d in self._distro_items:
            icon         = CAT_ICONS.get(d.get("cat", ""), "•")
            # 💾 renders natively on Windows; on Linux use ⊙ (U+2299) which
            # is in every font and visually suggests a disk/persistence slot
            persist_flag = (" 💾" if IS_WINDOWS else " ⊙") if (d.get("persist") and d["persist"] != "NULL") else ""
            self.distro_listbox.insert(END, f"  {icon}  {d['name']}{persist_flag}")

    def on_distro_select(self, event=None):
        sel = self.distro_listbox.curselection()
        if not sel:
            return
        idx = sel[0]
        if idx >= len(self._distro_items):
            return
        d = self._distro_items[idx]
        self.selected_distro = d
        # Clear progress panel on new selection
        self._clear_progress()
        cat_label    = CATEGORIES.get(d.get("cat", ""), {}).get("label", d.get("cat", ""))
        cat_id   = d.get("cat", "")
        cat_color = CAT_COLORS.get(cat_id, C["muted"])
        cat_icon  = CAT_ICONS.get(cat_id, "•")
        self.detail_swatch.configure(bg=cat_color)
        self.detail_cat_icon.configure(text=cat_icon, fg=cat_color)
        self.detail_name.configure(text=d["name"])
        self.detail_cat.configure(text=cat_label)
        self.detail_pattern.configure(text=d.get("pattern", ""))
        # Homepage link
        self._detail_hp_url = d.get("hp", "")
        self.detail_hp.configure(text="↗ Homepage" if self._detail_hp_url else "")
        # Separator dot
        both = bool(self._detail_hp_url) and bool(d.get("dl", ""))
        self.detail_link_sep.configure(text="  ·  " if both else "")
        # Download link
        self._detail_dl_url = d.get("dl", "")
        self.detail_dl.configure(text="↓ Download" if self._detail_dl_url else "")
        # Description
        self.detail_desc.configure(text=d.get("description", ""))
        # Show Browse section (1)
        self.iso_sec.pack(fill="x", padx=14, pady=(8, 0))

        # Show/hide Persistence section (2) - always start unchecked
        persist_type = d.get("persist", "NULL")
        is_vhd_cat   = d.get("cat", "") == "Windows-On-VHD"
        # VHD distros use their own size slider - suppress persistence section
        supports_persist = bool(persist_type and persist_type != "NULL") and not is_vhd_cat
        self._persist_type = persist_type  # "live", "casper", "vtoycow", "NULL"
        if supports_persist:
            self.persist_sec.pack(fill="x", padx=14, pady=(4, 0))
        else:
            self.persist_sec.pack_forget()
        self.persist_var.set(False)
        self._update_persist_row()

        # Show/hide VHD size section
        if is_vhd_cat:
            self.vhd_sec.pack(fill="x", padx=14, pady=(4, 0))
            self._on_vhd_slider()
        else:
            self.vhd_sec.pack_forget()

        # Hide Add button until an ISO is selected
        self.install_sec.pack_forget()

    def on_open_homepage(self, event=None):
        url = self._detail_hp_url
        if url:
            _open_url(url)

    def on_open_download(self, event=None):
        url = self._detail_dl_url
        if url:
            _open_url(url)

    def on_distro_open_url(self, event=None):
        url = self._detail_dl_url or self._detail_hp_url
        if url:
            _open_url(url)

    # ── ISO browse ─────────────────────────────────────────────────────────────

    def browse_iso(self):
        # Build filetypes from selected distro's pattern if available
        if self.selected_distro and self.selected_distro.get("pattern"):
            raw = self.selected_distro["pattern"]
            patterns = " ".join(p.strip() for p in raw.split(";") if p.strip())
            filetypes = [(f"{self.selected_distro['name']} files", patterns),
                         ("All files", "*.*")]
        else:
            filetypes = [("ISO images", "*.iso *.img *.vhdx"), ("All files", "*.*")]
        path = filedialog.askopenfilename(
            title="Select ISO file",
            filetypes=filetypes,
        )
        if path:
            self.iso_path = path
            name = Path(path).name
            self.iso_label.configure(text=name, fg=C["text"])
            if not self.selected_distro:
                self._auto_match_distro(name)
            # Show Add to USB Drive button (3) now that an ISO is chosen
            self.install_sec.pack(fill="x", padx=14, pady=(8, 0))

    def _auto_match_distro(self, filename: str):
        fname = filename.lower()
        for d in DISTROS:
            for pat in d.get("pattern", "").split(";"):
                if fnmatch.fnmatch(fname, pat.strip().lower()):
                    self.selected_distro = d
                    for i, item in enumerate(self._distro_items):
                        if item is d:
                            self.distro_listbox.selection_clear(0, END)
                            self.distro_listbox.selection_set(i)
                            self.distro_listbox.see(i)
                            self.on_distro_select()
                            return
                    self.set_cat("all")
                    self.render_distro_list()
                    for i, item in enumerate(self._distro_items):
                        if item is d:
                            self.distro_listbox.selection_clear(0, END)
                            self.distro_listbox.selection_set(i)
                            self.distro_listbox.see(i)
                            self.on_distro_select()
                            return

    # ── Add Multiple ISOs (queued) ──────────────────────────────────────────────
    # This tab intentionally has no OS-level drag-and-drop. Because YUMI
    # relaunches itself elevated at startup (see main()/_relaunch_as_admin()),
    # the window runs at a higher integrity level than Explorer, and Windows'
    # UIPI silently blocks drop messages from a normal-integrity Explorer
    # window onto an elevated one - real drag-and-drop simply doesn't arrive.
    # Rather than show a drop target that looks interactive but never fires,
    # this tab is browse-only: pick any number of files at once and they queue
    # up here, ready to be sent to the drive one at a time.
    def _build_addqueue_panel(self):
        f = self.panels["addqueue"]
        self._drop_queue: List[dict] = []

        hdr = tk.Frame(f, bg=C["bg"])
        hdr.pack(fill="x", padx=16, pady=(12, 0))
        tk.Label(hdr, text="Add Multiple ISO/IMG/VHD(x) Files", bg=C["bg"], fg=C["text"],
                 font=UF(12, "bold")).pack(side="left")
        styled_button(hdr, "Browse for files…", self._browse_multiple_isos,
                      small=True).pack(side="right")
        divider(f).pack(fill="x", pady=(8, 0))

        outer = tk.Frame(f, bg=C["bg"])
        outer.pack(fill="both", expand=True, padx=16, pady=8)

        # ── Browse zone ────────────────────────────────────────────────────────
        zone_note = ("Browse and select any/all ISO/IMG/VHD(x) files you'd like to add.\n"
                     "They'll appear below and run in a queue.")
        self.drop_zone = tk.Frame(outer, bg=C["panel"], highlightthickness=2,
                                  highlightbackground=C["border"], highlightcolor=C["accent"],
                                  cursor="hand2")
        self.drop_zone.pack(fill="x", pady=(0, 10))
        # NOTE: the folder emoji (U+1F4C2) can't be passed as Label *text* on
        # Linux - stock Ubuntu/Debian system Tcl/Tk is built with
        # TCL_UTF_MAX=3 (BMP-only, up to U+FFFF) and raises
        # "character U+1f4c2 is above the range ... allowed by Tcl", which
        # crashes the whole app during UI construction. Render it as an
        # image instead (via the pre-extracted PNG glyph in
        # self._emoji_imgs) so it never goes through Tcl's string handling.
        folder_img = self._emoji_imgs.get("📂") if IS_LINUX else None
        if folder_img:
            self.drop_zone_lbl = tk.Label(
                self.drop_zone, image=folder_img, text=f"  {zone_note}",
                compound="left",
                bg=C["panel"], fg=C["muted"], font=UF(11), pady=28, justify="center",
            )
            self.drop_zone_lbl.image = folder_img  # keep a reference, avoid GC
        else:
            # Windows keeps the emoji (its Tcl/Tk isn't BMP-limited); Linux
            # falls back to a plain BMP glyph if PNG extraction ever fails
            # (e.g. no color-emoji font installed to extract glyphs from).
            icon_char = "📂" if IS_WINDOWS else "⊞"
            self.drop_zone_lbl = tk.Label(
                self.drop_zone, text=f"{icon_char}  {zone_note}",
                bg=C["panel"], fg=C["muted"], font=UF(11), pady=28, justify="center",
            )
        self.drop_zone_lbl.pack(fill="x")
        for w in (self.drop_zone, self.drop_zone_lbl):
            w.bind("<Button-1>", lambda e: self._browse_multiple_isos())

        # ── Queue list ─────────────────────────────────────────────────────────
        list_area = tk.Frame(outer, bg=C["bg"])
        list_area.pack(fill="both", expand=True)

        self._drop_canvas = tk.Canvas(list_area, bg=C["bg"], highlightthickness=0)
        drop_scroll = tk.Scrollbar(list_area, command=self._drop_canvas.yview)
        drop_scroll.pack(side="right", fill="y")
        self._drop_canvas.pack(side="left", fill="both", expand=True)
        self._drop_canvas.configure(yscrollcommand=drop_scroll.set)
        self.drop_rows_inner = tk.Frame(self._drop_canvas, bg=C["bg"])
        self._drop_canvas.create_window((0, 0), window=self.drop_rows_inner, anchor="nw")
        self.drop_rows_inner.bind(
            "<Configure>",
            lambda e: self._drop_canvas.configure(scrollregion=self._drop_canvas.bbox("all")),
        )

        self.drop_empty_lbl = tk.Label(
            self.drop_rows_inner, text="No files queued yet.",
            bg=C["bg"], fg=C["muted"], font=UF(10),
        )
        self.drop_empty_lbl.pack(pady=20)

        # ── Bottom action bar ──────────────────────────────────────────────────
        bottom = tk.Frame(outer, bg=C["bg"])
        bottom.pack(fill="x", pady=(8, 0))
        self.drop_add_all_btn = styled_button(
            bottom, "Add All to Drive", self._process_drop_queue, accent=True,
        )
        self.drop_add_all_btn.pack(side="left")
        styled_button(bottom, "Clear Queue", self._clear_drop_queue,
                      small=True).pack(side="left", padx=(8, 0))

    def _browse_multiple_isos(self):
        paths = filedialog.askopenfilenames(
            title="Select ISO/IMG/VHD(x) files",
            filetypes=[("Bootable images", "*.iso *.img *.vhd *.vhdx"), ("All files", "*.*")],
        )
        if paths:
            self._queue_files(paths)

    def _queue_files(self, paths):
        valid_ext = {".iso", ".img", ".vhd", ".vhdx"}
        added, skipped = 0, 0
        for raw in paths:
            path = Path(raw)
            if not path.is_file() or path.suffix.lower() not in valid_ext:
                skipped += 1
                continue
            if any(item["path"] == str(path) for item in self._drop_queue):
                continue
            self._add_drop_row(path)
            added += 1
        if added and self.drop_empty_lbl.winfo_manager():
            self.drop_empty_lbl.pack_forget()
        if skipped:
            messagebox.showwarning(
                "Some files skipped",
                f"{skipped} selected item(s) weren't .iso/.img/.vhd/.vhdx files and were skipped.",
            )

    def _add_drop_row(self, path: Path):
        distro       = match_distro_by_filename(path.name)
        category     = distro.get("cat", "Linux-ISOs") if distro else "Linux-ISOs"
        persist_type = distro.get("persist", "NULL") if distro else "NULL"
        is_vhd       = category == "Windows-On-VHD"
        supports_persist = bool(persist_type and persist_type != "NULL") and not is_vhd

        item = {
            "path": str(path), "name": path.name, "distro": distro,
            "category": category, "persist_type": persist_type,
            "persist_var": tk.BooleanVar(value=False),
            "persist_size_var": tk.StringVar(
                value="16384" if persist_type == "live" else "4096"),
            "state": "queued",
        }

        row = tk.Frame(self.drop_rows_inner, bg=C["panel"], pady=6)
        row.pack(fill="x", pady=2, padx=2)

        top = tk.Frame(row, bg=C["panel"])
        top.pack(fill="x", padx=8)
        icon = CAT_ICONS.get(category, "•")
        tk.Label(top, text=f"{icon}  {path.name}", bg=C["panel"], fg=C["text"],
                 font=UF(9, "bold"), anchor="w").pack(side="left", fill="x", expand=True)
        remove_btn = styled_button(top, "✕", command=lambda: self._remove_drop_row(item),
                                   small=True, danger=True)
        remove_btn.pack(side="right")
        item["remove_btn"] = remove_btn

        cat_label = CATEGORIES.get(category, {}).get("label", category)
        match_txt = distro["name"] if distro else "Not recognized - will use Linux-ISOs"
        tk.Label(row, text=f"{cat_label}  ·  {match_txt}", bg=C["panel"], fg=C["muted"],
                 font=UF(8), anchor="w").pack(fill="x", padx=8)

        if supports_persist:
            opts = tk.Frame(row, bg=C["panel"])
            opts.pack(fill="x", padx=8, pady=(3, 0))
            tk.Checkbutton(opts, text="Persistence", variable=item["persist_var"],
                          bg=C["panel"], fg=C["text"], activebackground=C["panel"],
                          selectcolor=C["panel"], font=UF(8), bd=0,
                          highlightthickness=0).pack(side="left")
            size_opts = (("8192", "8 GB"), ("16384", "16 GB"), ("32768", "32 GB")) \
                if persist_type == "live" \
                else (("1024", "1 GB"), ("2048", "2 GB"), ("4096", "4 GB"),
                      ("8192", "8 GB"), ("16384", "16 GB"), ("32768", "32 GB"))
            size_menu = ttk.Combobox(opts, textvariable=item["persist_size_var"],
                                     values=[v for v, _ in size_opts],
                                     width=7, state="readonly")
            size_menu.pack(side="left", padx=(6, 0))
            tk.Label(opts, text="MB", bg=C["panel"], fg=C["muted"],
                    font=UF(8)).pack(side="left", padx=(3, 0))

        bar_bg = tk.Frame(row, bg=C["panel2"], height=4)
        bar_bg.pack(fill="x", padx=8, pady=(5, 0))
        bar_bg.pack_propagate(False)
        bar_fg = tk.Frame(bar_bg, bg=C["accent"], height=4)
        bar_fg.place(x=0, y=0, relheight=1, relwidth=0)
        status_lbl = tk.Label(row, text="Queued", bg=C["panel"], fg=C["muted"], font=UF(8))
        status_lbl.pack(anchor="w", padx=8, pady=(2, 0))

        item.update({"row": row, "bar_fg": bar_fg, "status_lbl": status_lbl})
        self._drop_queue.append(item)

    def _remove_drop_row(self, item: dict):
        if item.get("state") == "running":
            return  # don't yank a row out from under an in-progress copy
        item["row"].destroy()
        self._drop_queue.remove(item)
        self._update_drop_empty_hint()

    def _clear_drop_queue(self):
        if any(item.get("state") == "running" for item in self._drop_queue):
            messagebox.showwarning("Busy", "Wait for the current copy to finish first.")
            return
        for item in list(self._drop_queue):
            item["row"].destroy()
        self._drop_queue.clear()
        self._update_drop_empty_hint()

    def _update_drop_empty_hint(self):
        if not self._drop_queue and not self.drop_empty_lbl.winfo_manager():
            self.drop_empty_lbl.pack(pady=20)

    def _process_drop_queue(self):
        d = self.selected_drive
        if not d:
            messagebox.showwarning("No drive", "Please select a drive first.")
            return
        pending = [it for it in self._drop_queue if it["state"] == "queued"]
        if not pending:
            messagebox.showinfo("Add Multiple", "Nothing queued to add.")
            return
        self.drop_add_all_btn.configure(state="disabled")
        self._run_next_drop_item(list(pending))

    def _run_next_drop_item(self, remaining: List[dict]):
        """Process the queue one file at a time - writing several files to the
        same USB device concurrently would just contend for the same disk
        I/O and make every progress bar crawl, so this is intentionally
        sequential even though each row has its own progress bar."""
        if not remaining:
            self.drop_add_all_btn.configure(state="normal")
            self._refresh_drives_and_status()
            return

        item, rest = remaining[0], remaining[1:]
        d = self.selected_drive
        item["state"] = "running"
        item["remove_btn"].configure(state="disabled")
        item["status_lbl"].configure(text="Starting…", fg=C["muted"])

        distro = item["distro"]
        body = {
            "drive":      d.get("path", d.get("device", "")),
            "isoName":    item["name"],
            "category":   item["category"],
            "distroName": distro["name"] if distro else Path(item["name"]).stem,
            "isoPath":    item["path"],
            "persistMb":  int(item["persist_size_var"].get())
                          if item["persist_var"].get() else 0,
        }

        job_q: queue.Queue = queue.Queue()
        threading.Thread(target=lambda: self._run_install_job(body, job_q),
                         daemon=True).start()
        self._poll_drop_item(item, job_q, rest)

    def _run_install_job(self, body: dict, job_q: "queue.Queue"):
        """Runs on a worker thread - must not touch Tk."""
        try:
            run_install(body, job_q)
        except Exception as exc:
            job_q.put({"type": "error", "text": f"Unexpected error: {exc}"})
            job_q.put({"type": "done", "rc": 1})

    def _poll_drop_item(self, item: dict, job_q: "queue.Queue", rest: List[dict]):
        try:
            while True:
                msg = job_q.get_nowait()
                t = msg.get("type")
                if t == "progress":
                    pct = max(0, min(100, msg.get("pct", 0)))
                    item["bar_fg"].place(relwidth=pct / 100)
                    item["status_lbl"].configure(text=msg.get("label", f"{pct}%"), fg=C["muted"])
                elif t == "log":
                    # Persistence creation on Linux streams dd/mke2fs output via
                    # sudo_script() as "log" messages (no pct attached). Those
                    # were previously dropped here entirely, so the row looked
                    # stuck on "Starting..." the whole time persistence was
                    # being created. Surface the latest line so it's visible.
                    text = (msg.get("text") or "").strip()
                    if text:
                        item["status_lbl"].configure(text=text, fg=C["muted"])
                elif t == "sudo_prompt":
                    # Same handling as the single-file progress dialog: the
                    # worker thread needs a password, so show the dialog on
                    # the main thread and hand the result back through the
                    # event. Without this, a queued job needing a fresh sudo
                    # prompt would hang until sudo_script()'s 2-minute timeout.
                    pw = _ask_sudo_password()
                    msg["result"][0] = pw
                    msg["event"].set()
                elif t == "error":
                    item["last_error"] = msg.get("text", "").strip()
                    item["status_lbl"].configure(text=msg.get("text", "Error"), fg=C["danger"])
                elif t == "done":
                    ok = msg.get("rc", 1) == 0
                    item["state"] = "done" if ok else "error"
                    item["bar_fg"].configure(bg=C["success"] if ok else C["danger"])
                    if ok:
                        item["bar_fg"].place(relwidth=1.0)
                    fail_text = item.get("last_error") or "Failed (no error message reported)"
                    item["status_lbl"].configure(
                        text="Done" if ok else fail_text,
                        fg=C["success"] if ok else C["danger"],
                    )
                    item["remove_btn"].configure(state="normal")
                    self.after(300, lambda: self._run_next_drop_item(rest))
                    return
        except queue.Empty:
            pass
        self.after(100, lambda: self._poll_drop_item(item, job_q, rest))

    # ── Persistence toggle ─────────────────────────────────────────────────────

    def _on_vhd_slider(self, val=None):
        mb = self.vhd_size_mb_var.get()
        gb = mb / 1024
        self.vhd_size_lbl.configure(text=f"{mb} MB (~{gb:.0f} GB)")

    def _on_persist_slider(self, val=None):
        mb = self.persist_mb_var.get()
        gb = mb / 1024
        if gb >= 1:
            self.persist_size_lbl.configure(text=f"Size: {mb} MB ({gb:.1f} GB)")
        else:
            self.persist_size_lbl.configure(text=f"Size: {mb} MB")

    def _update_persist_row(self):
        enabled    = self.persist_var.get()
        state      = "normal" if enabled else "disabled"
        persist_type = getattr(self, "_persist_type", "casper")
        use_live   = (persist_type == "live")  # Debian-style: zip picker

        if use_live:
            # Show radio picker, hide continuous slider
            self.persist_slider_frame.pack_forget()
            if enabled:
                self.persist_live_frame.pack(fill="x")
            else:
                self.persist_live_frame.pack_forget()
        else:
            # Show continuous slider, hide radio picker
            self.persist_live_frame.pack_forget()
            self.persist_slider_frame.pack(fill="x")
            self.persist_slider.configure(state=state)
            self._on_persist_slider()

    # ── Prepare / Wipe ─────────────────────────────────────────────────────────

    def on_prepare_click(self):
        d = self.selected_drive
        if not d:
            messagebox.showwarning("No drive", "Please select a drive first.")
            return
        mode = "update" if d.get("prepared") else "install"
        if mode == "install":
            ok = messagebox.askyesno(
                "Confirm",
                f"This will ERASE all data on {d.get('label','the drive')} "
                f"and install YUMI with a Ventoy Bootloader.\n\nContinue?",
            )
            if not ok:
                return
        self._start_job(run_prepare, {
            "device":     d.get("device", d.get("path", "")),
            "mode":       mode,
            "label":      "YUMI",
            "nosb":       self.vtoy_nosb_var.get(),
            "fs":         self.vtoy_fs_var.get(),
            "reserve_mb": self.vtoy_reserve_var.get().strip() or "0",
        }, title=f"{'Updating' if mode=='update' else 'Preparing'} Drive…",
           on_done=self._refresh_drives_and_status)

    def _refresh_drives_and_status(self, post_callback=None):
        """Kick off polling - waits for udev to settle before refreshing."""
        self.after(1500, self._refresh_drives_poll, 0, post_callback)

    def _refresh_drives_poll(self, attempt: int, post_callback=None):
        """Poll list_drives until the partition appears mounted (max ~14 s)."""
        self.refresh_drives()
        d = self.selected_drive
        mounted = d and bool(d.get("path") and Path(d["path"]).is_dir())
        if not mounted and attempt < 8:
            self.after(1500, self._refresh_drives_poll, attempt + 1, post_callback)
            return
        # Mounted (or timed out) - do a final deep status re-read
        if d:
            self._refresh_drive_data(d)
            self._update_drive_info(d)
        self.refresh_installed()
        if post_callback:
            post_callback()

    def on_wipe_click(self):
        d = self.selected_drive
        if not d:
            messagebox.showwarning("No drive", "Please select a drive first.")
            return

        label_dlg = Toplevel(self)
        label_dlg.title("Wipe & Format")
        label_dlg.configure(bg=C["bg"])
        label_dlg.resizable(False, False)
        label_dlg.grab_set()
        label_var = tk.StringVar(value="USB")

        tk.Label(label_dlg,
                 text="Wipe & format drive as exFAT\n\nVolume label (max 11 chars):",
                 bg=C["bg"], fg=C["text"], font=UF(10), justify="left").pack(padx=20, pady=(16, 6))
        lbl_entry = Entry(label_dlg, textvariable=label_var,
                          bg=C["entry_bg"], fg=C["text"], insertbackground=C["text"],
                          font=UF(11), width=16, relief="flat", bd=4)
        lbl_entry.pack(padx=20, pady=(0, 12))
        lbl_entry.focus_set()
        lbl_entry.icursor("end")

        result = {"go": False, "label": "USB"}

        def _validate_label(val):
            import re as _re
            return bool(val) and len(val) <= 11 and not _re.search(r'[\\/:*?"<>|]', val)

        btn_row = tk.Frame(label_dlg, bg=C["bg"])
        btn_row.pack(pady=(0, 16))

        def _go(event=None):
            lbl = label_var.get().strip() or "USB"
            if not _validate_label(lbl):
                messagebox.showwarning("Invalid label",
                                       "Label must be 1–11 characters and contain no \\ / : * ? \" < > |",
                                       parent=label_dlg)
                return
            result["label"] = lbl
            result["go"] = True
            label_dlg.destroy()

        def _cancel(event=None):
            label_dlg.destroy()

        tk.Button(btn_row, text="Cancel", command=_cancel,
                  bg=C["panel2"], fg=C["muted"], relief="flat",
                  font=UF(10), padx=12, pady=4).pack(side="left", padx=6)
        tk.Button(btn_row, text="Wipe & Format", command=_go,
                  bg=C["danger"], fg="white", relief="flat",
                  font=UF(10, "bold"), padx=12, pady=4).pack(side="left", padx=6)

        label_dlg.bind("<Return>", _go)
        label_dlg.bind("<Escape>", _cancel)
        label_dlg.update_idletasks()
        try:
            rx = self.winfo_rootx() + self.winfo_width()  // 2 - label_dlg.winfo_width()  // 2
            ry = self.winfo_rooty() + self.winfo_height() // 2 - label_dlg.winfo_height() // 2
            label_dlg.geometry(f"+{rx}+{ry}")
        except Exception:
            pass
        self.wait_window(label_dlg)

        if not result["go"]:
            return

        drive_name = d.get("label") or d.get("device") or d.get("path") or "the drive"
        ok = messagebox.askyesno(
            "Confirm wipe",
            f"ALL DATA on {drive_name} will be permanently erased and the drive\n"
            f"will be formatted as exFAT with label \"{result['label']}\".\n\n"
            "This cannot be undone. Are you sure?",
            icon="warning",
        )
        if not ok:
            return

        device = d.get("device", d.get("path", ""))
        self._start_job(run_wipe, {
            "device":  device,
            "diskNum": d.get("disk_num", ""),
            "label":   result["label"],
            "fs":      "exFAT",
        }, title="Wiping & formatting drive…", on_done=self.refresh_drives)

    # ── Install ────────────────────────────────────────────────────────────────

    def do_install(self):
        d = self.selected_drive
        if not d:
            messagebox.showwarning("No drive", "Select a USB drive first.")
            return
        if not d.get("prepared"):
            ok = messagebox.askyesno(
                "Drive not prepared",
                f"{d.get('label', 'The selected drive')} has not been prepared with YUMI.\n\n"
                "This will ERASE all data on the drive and install YUMI before adding the ISO.\n\n"
                "Prepare now and continue?",
                icon="warning",
            )
            if not ok:
                return
            mode = "update" if d.get("prepared") else "install"
            self._start_job(run_prepare, {
                "device":     d.get("device", d.get("path", "")),
                "mode":       mode,
                "label":      "YUMI",
                "nosb":       self.vtoy_nosb_var.get(),
                "fs":         self.vtoy_fs_var.get(),
                "reserve_mb": self.vtoy_reserve_var.get().strip() or "0",
            }, title=f"{'Updating' if mode == 'update' else 'Preparing'} Drive\u2026",
               on_done=lambda: self._refresh_drives_and_status(post_callback=self.do_install))
            return
        if not self.iso_path:
            messagebox.showwarning("No ISO", "Browse for an ISO file first.")
            return
        if not self.selected_distro:
            messagebox.showwarning("No distro",
                                   "Select a distro from the list (or select 'Unlisted ISOs').")
            return

        distro   = self.selected_distro
        iso_name = Path(self.iso_path).name
        category = distro.get("cat", "Linux-ISOs")

        if category == "Windows-On-VHD":
            self._start_job(run_vhd_install, {
                "drive":      d.get("path") or d.get("device", ""),
                "isoName":    iso_name,
                "isoPath":    self.iso_path,
                "distroName": distro.get("name", ""),
                "vhdSizeMb":  self.vhd_size_mb_var.get(),
            }, title=f"Installing {iso_name} to VHD…",
               on_done=self._refresh_installed_after_change)
        else:
            self._start_job(run_install, {
                "drive":      d.get("path") or d.get("device", ""),
                "isoName":    iso_name,
                "category":   category,
                "distroName": distro.get("name", ""),
                "isoPath":    self.iso_path,
                "persistMb":  (int(self.persist_live_var.get())
                               if getattr(self, "_persist_type", "casper") == "live"
                               else self.persist_mb_var.get()
                               ) if self.persist_var.get() else 0,
            }, title=f"Installing {iso_name}…",
               on_done=self._refresh_installed_after_change)

    # ── Installed panel ────────────────────────────────────────────────────────

    def _refresh_installed_after_change(self):
        """Use after a job that modified the drive (install/remove) so the
        cached _installed list (and Ready/used-space status) reflects the
        change, rather than rendering the now-stale cache."""
        d = self.selected_drive
        if d:
            self._refresh_drive_data(d)
            self._update_drive_info(d)
        self.refresh_installed()

    def refresh_installed(self):
        for widget in self.installed_frame.winfo_children():
            widget.destroy()

        d = self.selected_drive
        if not d:
            tk.Label(self.installed_frame, text="Select a drive to see installed ISOs.",
                     bg=C["bg"], fg=C["muted"], font=UF(10)).pack(pady=30)
            return

        details = d.get("_installed")
        if details is None:
            tk.Label(self.installed_frame,
                     text="Drive not mounted or not accessible.",
                     bg=C["bg"], fg=C["muted"], font=UF(10)).pack(pady=30)
            return

        if not details:
            tk.Label(self.installed_frame, text="No ISOs installed on this drive.",
                     bg=C["bg"], fg=C["muted"], font=UF(10)).pack(pady=30)
            return

        canvas    = tk.Canvas(self.installed_frame, bg=C["bg"], highlightthickness=0)
        scrollbar = tk.Scrollbar(self.installed_frame, command=canvas.yview)
        scrollbar.pack(side="right", fill="y")
        canvas.pack(side="left", fill="both", expand=True)
        canvas.configure(yscrollcommand=scrollbar.set)
        inner = tk.Frame(canvas, bg=C["bg"])
        canvas.create_window((0, 0), window=inner, anchor="nw")
        inner.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))

        for item in details:
            cat   = item["category"]
            fname = item["filename"]
            entry = item["installedLine"]
            icon  = CAT_ICONS.get(cat, "\u25cb")
            row   = tk.Frame(inner, bg=C["panel"], pady=6)
            row.pack(fill="x", pady=2)

            label_text = f"  {icon}  {fname}"
            if item.get("persist"):
                # 💾 renders natively on Windows; on Linux use ⊙ (U+2299), which
                # is in every font and visually suggests a disk/persistence slot
                # (mirrors the same platform check in render_distro_list).
                label_text += "  💾" if IS_WINDOWS else "  ⊙"
            tk.Label(row, text=label_text, bg=C["panel"], fg=C["text"],
                     font=UF(10), anchor="w").pack(side="left", fill="x", expand=True)
            tk.Label(row, text=cat, bg=C["panel"], fg=C["muted"],
                     font=UF(9)).pack(side="left", padx=8)
            styled_button(row, "✕ Remove",
                          command=lambda e=entry: self.do_remove(e),
                          danger=True, small=True).pack(side="right", padx=8)

    def do_remove(self, entry: str):
        ok = messagebox.askyesno("Confirm removal",
                                 f"Remove {Path(entry.replace(chr(92),'/')).name} from the drive?")
        if not ok:
            return
        d          = self.selected_drive
        drive_path = d.get("path") or d.get("device", "")
        self._start_job(run_remove, {"drive": drive_path, "entry": entry},
                        title=f"Removing {entry}…", on_done=self._refresh_installed_after_change)

    # ── Job runner ─────────────────────────────────────────────────────────────

    def _start_job(self, fn, body: dict, title: str = "Working…", on_done=None):
        self._job_queue = queue.Queue()
        self._on_done   = on_done
        self._prog_pct  = 0

        # Show and reset the inline progress panel
        self._prog_title_lbl.configure(text=title, fg=C["text"])
        self._prog_pct_lbl.configure(text="")
        self._prog_bar.place(relwidth=0)
        self._prog_bar.configure(bg=C["accent"])
        self._prog_log.configure(state="normal")
        self._prog_log.delete("1.0", END)
        self._prog_log.configure(state="disabled")

        # Disable action buttons while job runs
        self.install_btn.configure(state="disabled")
        self.prep_chk.configure(state="disabled")

        def worker():
            try:
                fn(body, self._job_queue)
            except Exception as exc:
                self._job_queue.put({"type": "error", "text": f"Unexpected error: {exc}"})
                self._job_queue.put({"type": "done", "rc": 1})

        threading.Thread(target=worker, daemon=True).start()

    def _clear_progress(self):
        """Reset the always-visible progress panel to its idle state."""
        self._prog_title_lbl.configure(text="Progress", fg=C["muted"])
        self._prog_pct_lbl.configure(text="")
        self._prog_bar.place(relwidth=0)
        self._prog_bar.configure(bg=C["accent"])
        self._prog_log.configure(state="normal")
        self._prog_log.delete("1.0", END)
        self._prog_log.configure(state="disabled")

    def _prog_append(self, text: str, typ: str, pct: Optional[int], rc: int = 0):
        if pct is not None:
            self._prog_bar.place(relwidth=min(pct / 100, 1.0))
            self._prog_pct_lbl.configure(text=f"{pct}%")
        if text:
            self._prog_log.configure(state="normal")
            tag = typ if typ in ("success", "error", "warn") else "log"
            self._prog_log.insert(END, text + "\n", tag)
            self._prog_log.see(END)
            self._prog_log.configure(state="disabled")
        if typ == "done":
            self._prog_bar.place(relwidth=1.0)
            done_color = C["success"] if rc == 0 else C["danger"]
            self._prog_bar.configure(bg=done_color)
            self._prog_pct_lbl.configure(text="Done ✓" if rc == 0 else "Failed ✗")
            self.install_btn.configure(state="normal")
            self.prep_chk.configure(state="normal")

    def _poll_job(self):
        jq = self._job_queue
        if jq is not None:
            try:
                while True:
                    msg  = jq.get_nowait()
                    typ  = msg.get("type", "log")
                    text = msg.get("text", "")
                    pct  = msg.get("pct")
                    rc   = msg.get("rc", 0)
                    if typ == "sudo_prompt":
                        # Worker needs a sudo password - show dialog on main thread,
                        # write result back into msg["result"], then signal the event.
                        pw = _ask_sudo_password()
                        msg["result"][0] = pw
                        msg["event"].set()
                        continue
                    self._prog_append(text, typ, pct, rc)
                    if typ == "done":
                        self._job_queue = None
                        if self._on_done:
                            self.after(200, self._on_done)
                        self._on_done = None
            except queue.Empty:
                pass
        self.after(100, self._poll_job)

    # ── Update check ──────────────────────────────────────────────────────────

    def check_for_updates(self, manual: bool = False):
        """Check pendrivelinux.com for a newer YUMI PY version.

        manual=True (user clicked "Check for Updates"): always reports a
        result, including "you're up to date" and network-failure messages.
        manual=False (silent startup check): stays quiet unless a newer
        version is actually available, mirroring the YUMI.nsi behavior where
        $RecentVersionDisplay only forces the "up to date" popup on demand.
        """
        if manual and hasattr(self, "_update_check_btn"):
            self._update_check_btn.configure(state="disabled", text="Checking…")

        def worker():
            latest = _fetch_latest_version()
            self.after(0, lambda: self._on_update_check_result(latest, manual))

        threading.Thread(target=worker, daemon=True).start()

    def _on_update_check_result(self, latest: str, manual: bool):
        if manual and hasattr(self, "_update_check_btn"):
            self._update_check_btn.configure(state="normal", text="Check for Updates")

        if not latest:
            if manual:
                detail = _last_update_check_error or "No details available."
                messagebox.showwarning(
                    "Update check failed",
                    "Could not check for the latest version.\n\n"
                    f"Details: {detail}\n\n"
                    "This is usually a network/firewall issue, but on older or "
                    "locked-down Windows machines it can also be a certificate "
                    "trust problem (see 'SSLCertVerificationError' above).\n\n"
                    "Please check your internet connection and try again.",
                )
            return

        if _parse_version(latest) <= _parse_version(APP_VERSION):
            if manual:
                messagebox.showinfo(
                    "Up to date",
                    f"You're currently using the most recent version of YUMI PY "
                    f"({APP_VERSION}).",
                )
            return

        go = messagebox.askyesno(
            "Update available",
            f"You're using YUMI PY {APP_VERSION}\n\n"
            f"YUMI PY {latest} is available for download.\n\n"
            "Would you like to go to the download page now?",
        )
        if go:
            _open_url(UPDATE_DOWNLOAD_URL)


# ── Progress dialog ────────────────────────────────────────────────────────────

class ProgressDialog(Toplevel):
    def __init__(self, parent, title: str):
        super().__init__(parent)
        self.title(title)
        self.configure(bg=C["bg"])
        self.resizable(True, True)
        self.geometry(f"{scaled(620)}x{scaled(380)}")
        self.grab_set()

        tk.Label(self, text=title, bg=C["bg"], fg=C["text"],
                 font=UF(11, "bold")).pack(padx=16, pady=(12, 4), anchor="w")
        self.progress_lbl = tk.Label(self, text="", bg=C["bg"], fg=C["muted"],
                                     font=UF(9))
        self.progress_lbl.pack(padx=16, anchor="w")
        self.pbar = ttk.Progressbar(self, mode="determinate", maximum=100)
        self.pbar.pack(fill="x", padx=16, pady=(4, 0))
        style = ttk.Style(self)
        style.configure("TProgressbar", troughcolor=C["panel"], background=C["accent"])

        log_frame = tk.Frame(self, bg=C["bg"])
        log_frame.pack(fill="both", expand=True, padx=16, pady=8)
        sb = tk.Scrollbar(log_frame)
        sb.pack(side="right", fill="y")
        self.log_text = tk.Text(log_frame, bg=C["panel"], fg=C["text"],
                                relief="flat", bd=0, font=MF(9),
                                yscrollcommand=sb.set, state="disabled")
        self.log_text.pack(fill="both", expand=True)
        sb.config(command=self.log_text.yview)

        self.log_text.tag_configure("success", foreground=C["success"])
        self.log_text.tag_configure("error",   foreground=C["danger"])
        self.log_text.tag_configure("warn",    foreground=C["warn"])
        self.log_text.tag_configure("log",     foreground=C["text"])

        self.close_btn = styled_button(self, "Close", self.destroy, small=True)
        self.close_btn.pack(pady=(0, 12))
        self.close_btn.configure(state="disabled")

    def append(self, text: str, typ: str = "log", pct: Optional[int] = None):
        if pct is not None:
            self.pbar["value"] = pct
            self.progress_lbl.configure(text=f"{pct}%")
        if text:
            self.log_text.configure(state="normal")
            tag = typ if typ in ("success", "error", "warn") else "log"
            self.log_text.insert(END, text + "\n", tag)
            self.log_text.see(END)
            self.log_text.configure(state="disabled")
        if typ == "done":
            self.pbar["value"] = 100
            self.close_btn.configure(state="normal")
            self.progress_lbl.configure(text="Done.")


# ── Entry point ────────────────────────────────────────────────────────────────

def _show_license_dialog() -> bool:
    """
    Show a brief disclaimer before the app starts. The GPL only requires
    the license to accompany the program and be available to the user -
    not a click-through ceremony - so this keeps it short with a link to
    the full text rather than forcing a scroll-to-bottom read.
    Returns True if the user clicks Continue, False if they decline/close.
    """
    root = tk.Tk(className="YUMI")
    _init_fonts()  # resolve UI/mono font families now that a Tk root exists
    _init_scale(root)  # resolve display scale now that a Tk root exists
    root.title("YUMI")
    try:
      icon_path = BASE_DIR / "yumi_icon.png"
      if icon_path.exists():
        _disclaimer_icon = PhotoImage(file=str(icon_path))
        root.iconphoto(True, _disclaimer_icon)
      if IS_WINDOWS:
        ico_path = BASE_DIR / "yumi.ico"
        if ico_path.exists():
            root.iconbitmap(default=str(ico_path))
    except Exception:
        pass
    # Start at a reasonable default size; this gets replaced below with the
    # window's *actual* required size once content is laid out, so the
    # buttons can never end up pushed outside a stale hardcoded geometry
    # if fonts render larger than expected (e.g. HiDPI/scaling mismatches).
    root.geometry(f"{scaled(480)}x{scaled(320)}")
    root.configure(bg=C["bg"])
    root.resizable(False, False)

    accepted = {"value": False}

    body = tk.Frame(root, bg=C["bg"])
    body.pack(fill="both", expand=True, padx=24, pady=20)

    tk.Label(
        body, text="YUMI Disclaimer",
        bg=C["bg"], fg=C["text"], font=UF(13, "bold"),
    ).pack(anchor="w")

    notice = (
        "This tool is provided as-is, with no warranty of any kind. "
        "By continuing, you accept full responsibility for any data "
        "loss or damage that may result from its use.\n\n"
        "YUMI is free, open-source software released under the GNU "
        "GPL. It bundles Ventoy, 7-Zip, mke2fs, and dd, each under "
        "their own open-source license."
    )
    tk.Label(
        body, text=notice, bg=C["bg"], fg=C["text"],
        font=UF(10), justify="left", wraplength=430,
    ).pack(anchor="w", pady=(12, 0))

    license_path = BASE_DIR / "YUMI-Copying.txt"
    if license_path.exists():
        def _show_license_text():
            # Read and display the license in-app instead of shelling out to
            # an external opener/browser. On Linux, when YUMI is run via
            # sudo/pkexec, BASE_DIR (and this file) live in the root-owned
            # PyInstaller onefile extraction dir (/tmp/_MEIxxxxxx, mode 0700).
            # _open_url() correctly drops privileges to the real desktop user
            # before calling xdg-open, but that means an unprivileged xdg-open
            # can't read a root-only temp file -> "permission denied". Reading
            # the file ourselves (still running as root/this process) and
            # rendering it in a Tk Text widget sidesteps that entirely, and
            # satisfies the GPL requirement that the license be available
            # without forcing a scroll-to-bottom click-through ceremony.
            try:
                text = license_path.read_text(encoding="utf-8", errors="replace")
            except Exception as e:
                messagebox.showerror("License", f"Could not read license file:\n{e}")
                return

            win = Toplevel(root)
            win.title("YUMI-Copying.txt")
            win.configure(bg=C["bg"])
            win.geometry(f"{scaled(640)}x{scaled(480)}")

            frame = tk.Frame(win, bg=C["bg"])
            frame.pack(fill="both", expand=True, padx=10, pady=10)

            scrollbar = Scrollbar(frame)
            scrollbar.pack(side="right", fill="y")

            txt_widget = Text(
                frame, wrap="word", bg=C["panel2"], fg=C["text"],
                font=UF(9),
                yscrollcommand=scrollbar.set, relief="flat", bd=0,
                padx=10, pady=10,
            )
            txt_widget.insert("1.0", text)
            txt_widget.configure(state="disabled")
            txt_widget.pack(side="left", fill="both", expand=True)
            scrollbar.configure(command=txt_widget.yview)

            tk.Button(
                win, text="Close", bg=C["panel2"], fg=C["text"],
                activebackground=C["border"], activeforeground=C["text"],
                relief="flat", bd=0, font=UF(10), padx=18, pady=6,
                command=win.destroy,
            ).pack(pady=(0, 10))

            win.transient(root)
            win.grab_set()

        link = tk.Button(
            body, text="View full license (YUMI-Copying.txt)",
            bg=C["bg"], fg=C["accent"], activeforeground=C["accent2"],
            relief="flat", bd=0, highlightthickness=0, cursor="hand2",
            font=UF(9, "underline"),
            command=_show_license_text,
        )
        link.pack(anchor="w", pady=(10, 0))

    btn_row = tk.Frame(body, bg=C["bg"])
    btn_row.pack(fill="x", side="bottom", pady=(20, 0))

    tk.Button(
        btn_row, text="Accept", bg=C["accent"], fg="#ffffff",
        activebackground=C["accent2"], activeforeground="#ffffff", relief="flat", bd=0,
        font=UF(10, "bold"), padx=18, pady=7,
        command=lambda: (accepted.__setitem__("value", True), root.destroy()),
    ).pack(side="right")

    tk.Button(
        btn_row, text="Cancel", bg=C["panel2"], fg=C["text"],
        activebackground=C["border"], activeforeground=C["text"], relief="flat", bd=0,
        font=UF(10), padx=18, pady=7,
        command=root.destroy,
    ).pack(side="right", padx=(0, 8))

    root.protocol("WM_DELETE_WINDOW", root.destroy)  # closing = decline

    # Resize to whatever the widgets actually need once laid out, so a
    # scaling/DPI mismatch can never leave the Accept/Cancel buttons
    # clipped outside a stale fixed-size window. Floor at the original
    # default size (never shrink below it), and cap at the screen size.
    root.update_idletasks()
    req_w = max(scaled(480), root.winfo_reqwidth() + scaled(16))
    req_h = max(scaled(320), root.winfo_reqheight() + scaled(16))
    req_w = min(req_w, root.winfo_screenwidth() - 40)
    req_h = min(req_h, root.winfo_screenheight() - 80)
    root.geometry(f"{req_w}x{req_h}")

    root.eval('tk::PlaceWindow . center')
    root.mainloop()

    return accepted["value"]


def main():
    # Must happen before any Tk()/messagebox call below, including the
    # admin-elevation prompt a few lines down.
    _set_windows_dpi_awareness()

    # Windows: request admin elevation if not already elevated.
    # Check this first - if we relaunch as admin, no point showing the
    # disclaimer twice (once now, once again after relaunch).
    if IS_WINDOWS and not _is_admin():
        answer = messagebox.askyesno(
            "Administrator required",
            "YUMI needs Administrator privileges to prepare drives and create "
            "persistence files.\n\nRestart as Administrator now?",
        )
        if answer:
            _relaunch_as_admin()
            return
        # User said no - continue without elevation (ISO copy still works)

    # Show license/disclaimer - must accept to continue
    if not _show_license_dialog():
        sys.exit(0)

    app = YumiApp()
    style = ttk.Style(app)
    try:
        style.theme_use("clam")
    except Exception:
        pass
    # Apply the resolved clean font as the Tk-wide default, and explicitly to
    # ttk widgets (ttk ignores the classic Tk option database in some themes).
    _ui_font = UF(9)
    app.option_add("*Font", _ui_font)
    style.configure(".", font=_ui_font)
    style.configure("TCombobox",
                    fieldbackground=C["entry_bg"],
                    background=C["panel2"],
                    foreground=C["text"],
                    selectbackground=C["panel2"],
                    selectforeground=C["text"],
                    arrowcolor=C["text"],
                    font=_ui_font)
    style.map("TCombobox", fieldbackground=[("readonly", C["entry_bg"])])
    app.mainloop()

# ── Update check ────────────────────────────────────────────────────────────
def _parse_version(v: str) -> tuple:
    """Turn '1.10.2' into (1, 10, 2) for a numeric comparison. Falls back to
    an empty tuple for anything that doesn't look like a version number."""
    parts = re.split(r"[._-]", v.strip())
    out = []
    for p in parts:
        if p.isdigit():
            out.append(int(p))
        else:
            break
    return tuple(out)


def _https_context():
    """Build an SSL context with a CA bundle we can actually trust at runtime.

    The Linux build (builder.sh) provisions Python via 'uv --python-preference
    only-managed', which installs a portable python-build-standalone
    interpreter. Those builds ship their own OpenSSL with default CA cert
    paths baked in from the *build* machine (e.g. wherever uv's CI ran),
    which usually don't exist on the end user's machine. That makes
    urlopen()'s default SSL context fail cert verification, and since
    _fetch_latest_version() catches Exception broadly, "Check for Updates"
    just silently reports failure with no clue why.

    Prefer certifi's bundled CA file (add 'certifi' to builder.sh's `uv pip
    install` line so PyInstaller picks it up as a dependency), and fall back
    to the system's default verify paths otherwise.
    """
    try:
        import certifi
        return ssl.create_default_context(cafile=certifi.where())
    except Exception:
        pass
    try:
        return ssl.create_default_context()
    except Exception:
        return None


_last_update_check_error = ""  # set by _fetch_latest_version() on failure, for diagnostics


def _fetch_latest_version(timeout: int = 6) -> str:
    """Download and return the latest version string from UPDATE_CHECK_URL,
    or '' on any failure (offline, timeout, 404, cert error, etc.).

    On failure, the exception detail is stashed in _last_update_check_error
    so the UI can show *why* it failed instead of always guessing "no
    internet connection" - e.g. a stale/incomplete Windows trust store
    surfaces as ssl.SSLCertVerificationError, not a connection error."""
    global _last_update_check_error
    _last_update_check_error = ""
    try:
        req = urllib.request.Request(
            UPDATE_CHECK_URL, headers={"User-Agent": "YUMI-GUI-Update-Check"}
        )
        ctx = _https_context()
        with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
            raw = resp.read().decode("utf-8", errors="ignore")
        return raw.strip().splitlines()[0].strip() if raw.strip() else ""
    except Exception as e:
        _last_update_check_error = f"{type(e).__name__}: {e}"
        return ""


# ── Linux browser fix ──────────────────────────────────────────────────────────
def _open_url(url: str) -> None:
    """Cross-platform URL opener that works correctly under sudo."""
    import shutil, subprocess as _sp, webbrowser as _wb, threading as _th, os as _os, glob as _glob

    def _real_user_env() -> Tuple[str, dict]:
        """
        When running as root via sudo, return (real_username, env_dict) with the
        original user's DISPLAY / XAUTHORITY / DBUS vars sourced from their live
        session.  Returns ("", {}) if we're not under sudo or can't determine it.
        """
        sudo_user = _os.environ.get("SUDO_USER", "").strip()
        if not sudo_user or sudo_user == "root":
            return "", {}

        # Find the user's UID so we can look up their runtime dir
        try:
            import pwd as _pwd
            uid = _pwd.getpwnam(sudo_user).pw_uid
        except Exception:
            uid = None

        env: Dict[str, str] = {}

        # 1. Harvest DISPLAY + XAUTHORITY from /proc/<pid>/environ for any
        #    process owned by the real user that has DISPLAY set.
        try:
            for pid_dir in _glob.glob("/proc/[0-9]*/environ"):
                try:
                    with open(pid_dir, "rb") as fh:
                        raw = fh.read()
                    pairs = {k: v for k, v in (
                        item.split(b"=", 1) for item in raw.split(b"\x00")
                        if b"=" in item
                    )}
                    if b"DISPLAY" not in pairs:
                        continue
                    # Check owner matches real user
                    if uid is not None:
                        import stat as _stat
                        st = _os.stat(pid_dir)
                        if st.st_uid != uid:
                            continue
                    for key in (b"DISPLAY", b"XAUTHORITY", b"DBUS_SESSION_BUS_ADDRESS",
                                b"WAYLAND_DISPLAY", b"XDG_RUNTIME_DIR"):
                        if key in pairs:
                            env[key.decode()] = pairs[key].decode(errors="replace")
                    if "DISPLAY" in env or "WAYLAND_DISPLAY" in env:
                        break
                except (OSError, PermissionError, ValueError):
                    continue
        except Exception:
            pass

        # 2. Fill in DBUS from the systemd runtime dir if still missing
        if "DBUS_SESSION_BUS_ADDRESS" not in env and uid is not None:
            bus = f"/run/user/{uid}/bus"
            if _os.path.exists(bus):
                env["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus}"

        # 3. Fallback DISPLAY guess
        if "DISPLAY" not in env and "WAYLAND_DISPLAY" not in env:
            env["DISPLAY"] = ":0"

        # 4. Always set HOME to the real user's home
        try:
            import pwd as _pwd2
            env["HOME"] = _pwd2.getpwnam(sudo_user).pw_dir
        except Exception:
            pass

        return sudo_user, env

    def _try():
        if IS_WINDOWS:
            try:
                _os.startfile(url)
                return
            except Exception:
                pass
            # Fallback: PowerShell
            try:
                _sp.Popen(["powershell", "-NoProfile", "-Command",
                           f"Start-Process '{url}'"],
                          stdout=_sp.DEVNULL, stderr=_sp.DEVNULL)
                return
            except Exception:
                pass

        if IS_LINUX:
            real_user, user_env = _real_user_env()
            running_as_root = (_os.geteuid() == 0)

            # Base env: inherit current environment, overlay real-user vars
            env = _os.environ.copy()
            env.update(user_env)

            # Ensure at least a DISPLAY guess if we still have nothing
            if "DISPLAY" not in env and "WAYLAND_DISPLAY" not in env:
                env["DISPLAY"] = ":0"

            openers = ["xdg-open", "gio open", "sensible-browser",
                       "x-www-browser", "firefox", "firefox-esr",
                       "chromium-browser", "chromium", "google-chrome",
                       "brave-browser", "epiphany", "midori", "falkon"]

            if running_as_root and real_user:
                # Strategy A: run opener as the real user so it inherits their
                # desktop session cleanly - this is the most reliable approach.
                drop_cmd = None
                if shutil.which("sudo"):
                    drop_cmd = ["sudo", "-u", real_user, "--preserve-env=DISPLAY,"
                                "XAUTHORITY,DBUS_SESSION_BUS_ADDRESS,"
                                "WAYLAND_DISPLAY,XDG_RUNTIME_DIR,HOME"]
                elif shutil.which("runuser"):
                    drop_cmd = ["runuser", "-u", real_user, "--"]
                elif shutil.which("su"):
                    # su -c needs a single shell string
                    drop_cmd = None  # handled separately below

                if drop_cmd:
                    for opener in openers:
                        # "gio open" is two words
                        parts = opener.split()
                        if not shutil.which(parts[0]):
                            continue
                        try:
                            _sp.Popen(
                                drop_cmd + parts + [url],
                                env=env,
                                stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
                                close_fds=True, start_new_session=True,
                            )
                            return
                        except Exception:
                            continue

                # Strategy B: su -c fallback
                if shutil.which("su"):
                    for opener in openers:
                        parts = opener.split()
                        if not shutil.which(parts[0]):
                            continue
                        shell_cmd = " ".join(_sp.list2cmdline([p]) for p in parts) \
                                    + " " + _sp.list2cmdline([url])
                        try:
                            _sp.Popen(
                                ["su", "-c", shell_cmd, real_user],
                                env=env,
                                stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
                                close_fds=True, start_new_session=True,
                            )
                            return
                        except Exception:
                            continue

            # Strategy C: direct launch (works when not under sudo, or as a
            # last-chance attempt even under sudo with the enriched env)
            for opener in openers:
                parts = opener.split()
                if not shutil.which(parts[0]):
                    continue
                try:
                    _sp.Popen(
                        parts + [url], env=env,
                        stdout=_sp.DEVNULL, stderr=_sp.DEVNULL,
                        close_fds=True, start_new_session=True,
                    )
                    return
                except Exception:
                    continue

        # Last resort: stdlib webbrowser (often broken under sudo but worth trying)
        try:
            _wb.open_new_tab(url)
            return
        except Exception:
            pass

        # Nothing worked - show URL so user can copy it
        messagebox.showinfo(
            "Open in browser",
            f"Could not launch a browser automatically.\n\nCopy this URL:\n{url}",
        )

    _th.Thread(target=_try, daemon=True).start()


# ── Windows admin helpers ──────────────────────────────────────────────────────
def _is_admin() -> bool:
    try:
        import ctypes
        return bool(ctypes.windll.shell32.IsUserAnAdmin())
    except Exception:
        return False


def _relaunch_as_admin() -> None:
    import ctypes
    params = " ".join(f'"{a}"' for a in sys.argv)
    rc = ctypes.windll.shell32.ShellExecuteW(None, "runas", sys.executable, params, None, 1)
    if rc <= 32:
        messagebox.showwarning(
            "Elevation failed",
            f"UAC elevation failed (code {rc}). Try right-clicking and choosing "
            "'Run as administrator'.",
        )
    sys.exit(0)


if __name__ == "__main__":
    main()