Skip to content

Instantly share code, notes, and snippets.

@whileloop99
Created April 10, 2026 08:06
Show Gist options
  • Select an option

  • Save whileloop99/03c7fa0a5156a4953b21c203ed094142 to your computer and use it in GitHub Desktop.

Select an option

Save whileloop99/03c7fa0a5156a4953b21c203ed094142 to your computer and use it in GitHub Desktop.
QT Installer Framework GUI
import sys
import os
import shutil
import json
from pathlib import Path
from typing import Optional
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
import jinja2
import subprocess
import threading
import time
import webbrowser
def _app_base_dir() -> Path:
"""Directory of the .exe when frozen (PyInstaller), else the script directory."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
# Writable settings next to the app; relative paths in JSON resolve against this folder.
SETTINGS_PATH = _app_base_dir() / "installer-builder.json"
# Staging directory for config/packages (next to exe when frozen, not process cwd)
BUILD_WORK_DIR = _app_base_dir() / "build"
# binarycreator writes the final .exe relative to cwd
INSTALLER_DIST_DIR = _app_base_dir() / "dist"
def _bundled_default_settings_path() -> Optional[Path]:
"""PyInstaller one-folder/one-file: optional packaged defaults inside the bundle."""
root = getattr(sys, "_MEIPASS", None)
if not root:
return None
p = Path(root) / "installer-builder.json"
return p if p.is_file() else None
def _resolve_user_path(path_str: str) -> Path:
"""Expand ~ and resolve paths relative to the app/settings directory (not cwd)."""
p = Path(path_str).expanduser()
if p.is_absolute():
return p
return (SETTINGS_PATH.parent / p).resolve()
# Qt IFW — Predefined Variables (Component Scripting)
IFW_PREDEFINED_VARS_DOC_URL = (
"https://doc.qt.io/qtinstallerframework/scripting.html#predefined-variables"
)
IFW_PREDEFINED_VARIABLES = (
("ProductName", "Name of the product to install, as defined in config.xml."),
("ProductVersion", "Product version, as defined in config.xml."),
("Title", "Installer window title, as defined in config.xml."),
("Publisher", "Publisher name, as defined in config.xml."),
("Url", "Product URL, as defined in config.xml."),
(
"StartMenuDir",
"Start menu group, as defined in config.xml. Windows only.",
),
("TargetDir", "Installation target directory chosen by the user."),
(
"DesktopDir",
"Directory that contains the user's desktop. Windows only.",
),
(
"os",
'Current platform: "x11", "win", or "mac". Deprecated; use systemInfo instead.',
),
(
"FrameworkVersion",
"Qt Installer Framework version used to build the installer.",
),
("RootDir", "Root directory of the filesystem."),
("HomeDir", "Current user's home directory."),
(
"ApplicationsDir",
"Applications directory (e.g. C:\\Program Files on Windows, /opt on Linux, /Applications on macOS).",
),
(
"ApplicationsDirUser",
"Per-user applications directory; useful on macOS ($HOME/Applications); elsewhere often same as ApplicationsDir.",
),
(
"ApplicationsDirX86",
"32-bit applications directory on Windows; elsewhere often same as ApplicationsDir.",
),
(
"ApplicationsDirX64",
"64-bit applications directory on Windows; elsewhere often same as ApplicationsDir.",
),
("InstallerDirPath", "Directory containing the installer executable."),
("InstallerFilePath", "Full path of the installer executable."),
(
"UserStartMenuProgramsPath",
"Current user's Start Menu\\Programs path. Windows only.",
),
(
"AllUsersStartMenuProgramsPath",
"All-users Start Menu\\Programs path. Windows only.",
),
("UILanguage", "Language used in the installer UI."),
)
# Combobox labels -> WizardStyle line in config.xml
INSTALLER_UI_LINES = {
"Modern (matches Windows dark/light)": " <WizardStyle>Modern</WizardStyle>\n",
"IFW default": "",
"Classic": " <WizardStyle>Classic</WizardStyle>\n",
"Aero": " <WizardStyle>Aero</WizardStyle>\n",
}
INSTALLER_UI_LABELS = tuple(INSTALLER_UI_LINES.keys())
LEGACY_INSTALLER_UI = {
"modern": "Modern (matches Windows dark/light)",
"default": "IFW default",
"classic": "Classic",
"aero": "Aero",
"Modern (theo Windows dark/light)": "Modern (matches Windows dark/light)",
"Mặc định IFW": "IFW default",
}
WINDOW_THEME_LABEL = {"system": "System", "light": "Light", "dark": "Dark"}
WINDOW_THEME_FROM_LABEL = {v: k for k, v in WINDOW_THEME_LABEL.items()}
THEME_LIGHT = {
"bg": "#e4e7ee",
"fg": "#0f172a",
"muted": "#64748b",
"card_bg": "#ffffff",
"header_bg": "#ffffff",
"entry_bg": "#f8fafc",
"entry_fg": "#0f172a",
"log_bg": "#f1f5f9",
"log_fg": "#1e293b",
"log_cursor": "#1e293b",
"select_bg": "#2563eb",
"select_fg": "#ffffff",
"accent": "#2563eb",
"accent_hover": "#1d4ed8",
"border": "#cbd5e1",
}
THEME_DARK = {
"bg": "#121212",
"fg": "#f1f5f9",
"muted": "#94a3b8",
"card_bg": "#1e1e1e",
"header_bg": "#181818",
"entry_bg": "#2a2a2a",
"entry_fg": "#f1f5f9",
"log_bg": "#0d0d0d",
"log_fg": "#cbd5e1",
"log_cursor": "#cbd5e1",
"select_bg": "#3b82f6",
"select_fg": "#ffffff",
"accent": "#3b82f6",
"accent_hover": "#2563eb",
"border": "#334155",
}
def _windows_prefers_light_apps():
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
)
try:
val, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
return int(val) != 0
finally:
winreg.CloseKey(key)
except OSError:
return True
def _effective_window_theme(mode):
if mode == "dark":
return "dark"
if mode == "light":
return "light"
if sys.platform == "win32" and not _windows_prefers_light_apps():
return "dark"
return "light"
def _ui_font(size=10, bold=False):
face = "Segoe UI" if sys.platform == "win32" else "Helvetica"
return (face, size, "bold") if bold else (face, size)
def _xml_escape(text):
if text is None:
return ""
s = str(text)
return (
s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
def _reveal_path_in_file_manager(path: Path) -> None:
"""Open Explorer (Windows), Finder (macOS), or a Linux file manager with ``path`` selected."""
p = path.expanduser().resolve()
if not p.is_file():
raise FileNotFoundError(f"Not a file: {p}")
if sys.platform == "win32":
import ctypes
full = os.path.normpath(str(p)).replace('"', "")
# explorer expects: /select,"path" when the path may contain spaces
params = f'/select,"{full}"'
windir = os.environ.get("WINDIR", r"C:\Windows")
explorer_exe = os.path.join(windir, "explorer.exe")
if not os.path.isfile(explorer_exe):
explorer_exe = "explorer.exe"
rc = ctypes.windll.shell32.ShellExecuteW(
None,
"open",
explorer_exe,
params,
str(p.parent),
1, # SW_SHOWNORMAL
)
if rc <= 32:
raise OSError(f"Explorer could not show the file (error code {int(rc)})")
elif sys.platform == "darwin":
subprocess.Popen(["/usr/bin/open", "-R", str(p)], close_fds=True)
else:
sp = str(p)
nautilus = shutil.which("nautilus")
if nautilus:
subprocess.Popen([nautilus, "--select", sp], close_fds=True)
return
dolphin = shutil.which("dolphin")
if dolphin:
subprocess.Popen([dolphin, "--select", sp], close_fds=True)
return
xdg_open = shutil.which("xdg-open")
if xdg_open:
subprocess.Popen([xdg_open, str(p.parent)], close_fds=True)
return
raise OSError("No supported file manager found (tried nautilus, dolphin, xdg-open).")
def _copy_file_into_dest_dir(src_path, dest_dir: Path) -> Optional[str]:
"""Copy file into dest_dir; return basename for XML references."""
if not src_path or not str(src_path).strip():
return None
p = _resolve_user_path(str(src_path))
if not p.is_file():
return None
dest = dest_dir / p.name
try:
if p.resolve() == (dest_dir / p.name).resolve() and p.parent.resolve() == dest_dir.resolve():
return p.name
except OSError:
pass
if dest.exists() and dest.resolve() != p.resolve():
stem, suf = p.stem, p.suffix
n = 1
while True:
cand = dest_dir / f"{stem}_{n}{suf}"
if not cand.exists():
dest = cand
break
n += 1
shutil.copy2(p, dest)
return dest.name
def _copy_asset_to_config(src_path, config_dir: Path) -> Optional[str]:
"""Copy file into config_dir; return filename for config.xml."""
return _copy_file_into_dest_dir(src_path, config_dir)
def _installer_app_icon_basename_for_xml(copied_filename: str) -> str:
"""InstallerApplicationIcon: basename without .ico/.icns extension."""
p = Path(copied_filename)
return p.stem
class QtIFWGui:
def __init__(self):
self.root = tk.Tk()
self.root.title("Qt IFW Generator")
self.root.geometry("960x640")
self.root.minsize(720, 480)
self.is_running = False
self._settings = self._load_settings()
_wt = self._settings.get("window_theme", "system")
if _wt not in WINDOW_THEME_LABEL:
_wt = "system"
self.window_theme_internal = tk.StringVar(value=_wt)
self.window_theme_display = tk.StringVar(value=WINDOW_THEME_LABEL[_wt])
_saved_ui = self._settings.get("installer_ui", "Modern (matches Windows dark/light)")
while _saved_ui in LEGACY_INSTALLER_UI:
_saved_ui = LEGACY_INSTALLER_UI[_saved_ui]
if _saved_ui not in INSTALLER_UI_LINES:
_saved_ui = "Modern (matches Windows dark/light)"
self.installer_ui_var = tk.StringVar(value=_saved_ui)
g = self._settings.get
self.name_var = tk.StringVar(value=g("name", "MyApplication"))
self.version_var = tk.StringVar(value=g("version", "1.0.0"))
self.publisher_var = tk.StringVar(value=g("publisher", "MyCompany"))
self.title_var = tk.StringVar(value=g("title", "@ProductName@ @ProductVersion@"))
self.target_var = tk.StringVar(value=g("target_dir", "@ApplicationsDir@/@Publisher@/@ProductName@"))
_sm = g("startmenu_dir", "") or g("name", "@Publisher@/@ProductName@")
self.startmenu_dir_var = tk.StringVar(value=_sm)
self.product_url_var = tk.StringVar(value=g("product_url", ""))
self.banner_path_var = tk.StringVar(value=g("banner_path", ""))
self.logo_path_var = tk.StringVar(value=g("logo_path", ""))
self.watermark_path_var = tk.StringVar(value=g("watermark_path", ""))
self.winicon_path_var = tk.StringVar(value=g("winicon_path", ""))
self.appicon_path_var = tk.StringVar(value=g("appicon_path", ""))
self.pkg_name_var = tk.StringVar(value=g("pkg_name", "com.mycompany.myapp"))
self.pkg_display_var = tk.StringVar(value=g("pkg_display", "Main Program"))
self.app_source_dir_var = tk.StringVar(value=g("app_source_dir", ""))
self.shortcut_name_var = tk.StringVar(value=g("shortcut_name", "@ProductName@"))
self.shortcut_target_var = tk.StringVar(value=g("shortcut_target", "@TargetDir@/@ProductName@.exe"))
self.shortcut_icon_var = tk.StringVar(value=g("shortcut_icon", ""))
self.shortcut_desktop_var = tk.BooleanVar(value=g("shortcut_desktop", True))
self.shortcut_startmenu_var = tk.BooleanVar(value=g("shortcut_startmenu", True))
self.run_after_install_var = tk.BooleanVar(value=g("run_after_install", False))
self.run_after_install_desc_var = tk.StringVar(
value=g("run_after_install_desc", "Run the application now.")
)
self.license_enabled_var = tk.BooleanVar(value=g("license_enabled", False))
self.license_path_var = tk.StringVar(value=g("license_path", ""))
self.license_title_var = tk.StringVar(value=g("license_title", "License Agreement"))
# Qt IFW: RemoveTargetDir=false skips blocking on non-empty target (uninstall won't remove whole folder).
self.allow_nonempty_target_var = tk.BooleanVar(value=g("allow_nonempty_target", False))
self.clean_temp_before_build_var = tk.BooleanVar(value=g("clean_temp_before_build", False))
self.qtifw_bin_var = tk.StringVar(value=g("qtifw_bin", ""))
self.build_env_extra_var = tk.StringVar(value=g("build_env_extra", ""))
self.style = ttk.Style()
if "clam" in self.style.theme_names():
self.style.theme_use("clam")
self.create_widgets()
self._apply_window_theme()
self.installer_ui_var.trace_add("write", lambda *_: self._persist_settings())
self.license_enabled_var.trace_add(
"write",
lambda *_: (self._sync_license_inputs_state(), self._persist_settings()),
)
self.run_after_install_var.trace_add(
"write",
lambda *_: (self._sync_run_after_install_state(), self._persist_settings()),
)
self.allow_nonempty_target_var.trace_add("write", lambda *_: self._persist_settings())
self.clean_temp_before_build_var.trace_add("write", lambda *_: self._persist_settings())
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _load_settings(self):
try:
if SETTINGS_PATH.is_file():
return json.loads(SETTINGS_PATH.read_text(encoding="utf-8"))
bundled = _bundled_default_settings_path()
if bundled is not None:
return json.loads(bundled.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
pass
return {}
def _gather_settings_dict(self):
return {
"window_theme": self.window_theme_internal.get(),
"installer_ui": self.installer_ui_var.get(),
"name": self.name_var.get(),
"version": self.version_var.get(),
"publisher": self.publisher_var.get(),
"title": self.title_var.get(),
"target_dir": self.target_var.get(),
"startmenu_dir": self.startmenu_dir_var.get(),
"product_url": self.product_url_var.get(),
"banner_path": self.banner_path_var.get(),
"logo_path": self.logo_path_var.get(),
"watermark_path": self.watermark_path_var.get(),
"winicon_path": self.winicon_path_var.get(),
"appicon_path": self.appicon_path_var.get(),
"pkg_name": self.pkg_name_var.get(),
"pkg_display": self.pkg_display_var.get(),
"app_source_dir": self.app_source_dir_var.get(),
"shortcut_name": self.shortcut_name_var.get(),
"shortcut_target": self.shortcut_target_var.get(),
"shortcut_icon": self.shortcut_icon_var.get(),
"shortcut_desktop": self.shortcut_desktop_var.get(),
"shortcut_startmenu": self.shortcut_startmenu_var.get(),
"run_after_install": self.run_after_install_var.get(),
"run_after_install_desc": self.run_after_install_desc_var.get(),
"license_enabled": self.license_enabled_var.get(),
"license_path": self.license_path_var.get(),
"license_title": self.license_title_var.get(),
"allow_nonempty_target": self.allow_nonempty_target_var.get(),
"clean_temp_before_build": self.clean_temp_before_build_var.get(),
"qtifw_bin": self.qtifw_bin_var.get(),
"build_env_extra": self.build_env_extra_var.get(),
}
def _persist_settings(self):
try:
data = self._gather_settings_dict()
SETTINGS_PATH.write_text(json.dumps(data, indent=2), encoding="utf-8")
except OSError:
pass
@staticmethod
def _json_bool(value, default: bool) -> bool:
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(int(value))
if isinstance(value, str):
return value.strip().lower() in ("1", "true", "yes", "on")
return default
def _apply_settings_from_dict(self, data: dict) -> None:
if not isinstance(data, dict):
raise TypeError("Settings file must contain a JSON object.")
if "window_theme" in data:
_wt = data["window_theme"]
if not isinstance(_wt, str) or _wt not in WINDOW_THEME_LABEL:
_wt = "system"
self.window_theme_internal.set(_wt)
self.window_theme_display.set(WINDOW_THEME_LABEL[_wt])
if "installer_ui" in data:
_saved_ui = data["installer_ui"]
if not isinstance(_saved_ui, str):
_saved_ui = "Modern (matches Windows dark/light)"
while _saved_ui in LEGACY_INSTALLER_UI:
_saved_ui = LEGACY_INSTALLER_UI[_saved_ui]
if _saved_ui not in INSTALLER_UI_LINES:
_saved_ui = "Modern (matches Windows dark/light)"
self.installer_ui_var.set(_saved_ui)
str_map = (
("name", self.name_var),
("version", self.version_var),
("publisher", self.publisher_var),
("title", self.title_var),
("target_dir", self.target_var),
("startmenu_dir", self.startmenu_dir_var),
("product_url", self.product_url_var),
("banner_path", self.banner_path_var),
("logo_path", self.logo_path_var),
("watermark_path", self.watermark_path_var),
("winicon_path", self.winicon_path_var),
("appicon_path", self.appicon_path_var),
("pkg_name", self.pkg_name_var),
("pkg_display", self.pkg_display_var),
("app_source_dir", self.app_source_dir_var),
("shortcut_name", self.shortcut_name_var),
("shortcut_target", self.shortcut_target_var),
("shortcut_icon", self.shortcut_icon_var),
("run_after_install_desc", self.run_after_install_desc_var),
("license_path", self.license_path_var),
("license_title", self.license_title_var),
("qtifw_bin", self.qtifw_bin_var),
("build_env_extra", self.build_env_extra_var),
)
for key, var in str_map:
if key not in data:
continue
v = data[key]
var.set("" if v is None else str(v))
bool_map = (
("shortcut_desktop", self.shortcut_desktop_var, True),
("shortcut_startmenu", self.shortcut_startmenu_var, True),
("run_after_install", self.run_after_install_var, False),
("license_enabled", self.license_enabled_var, False),
("allow_nonempty_target", self.allow_nonempty_target_var, False),
("clean_temp_before_build", self.clean_temp_before_build_var, False),
)
for key, var, default in bool_map:
if key not in data:
continue
var.set(self._json_bool(data[key], default))
self._apply_window_theme()
self._sync_license_inputs_state()
self._sync_run_after_install_state()
self._persist_settings()
def _export_settings(self):
path = filedialog.asksaveasfilename(
parent=self.root,
title="Export settings",
defaultextension=".json",
filetypes=[("JSON settings", "*.json"), ("All files", "*.*")],
initialfile="installer-builder.json",
)
if not path:
return
try:
Path(path).write_text(
json.dumps(self._gather_settings_dict(), indent=2),
encoding="utf-8",
)
except OSError as e:
messagebox.showerror("Export failed", str(e), parent=self.root)
def _import_settings(self):
path = filedialog.askopenfilename(
parent=self.root,
title="Import settings",
filetypes=[("JSON settings", "*.json"), ("All files", "*.*")],
)
if not path:
return
try:
raw = Path(path).read_text(encoding="utf-8")
data = json.loads(raw)
except (OSError, json.JSONDecodeError) as e:
messagebox.showerror("Import failed", str(e), parent=self.root)
return
try:
self._apply_settings_from_dict(data)
except TypeError as e:
messagebox.showerror("Import failed", str(e), parent=self.root)
return
messagebox.showinfo("Import", "Settings loaded and saved.", parent=self.root)
def _build_subprocess_env(self):
"""Return env dict only if Qt IFW bin or extra KEY=value lines are set; else None (inherit process env)."""
bin_dir = self.qtifw_bin_var.get().strip()
has_extra = False
for line in self.build_env_extra_var.get().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
has_extra = True
break
if not bin_dir and not has_extra:
return None
env = os.environ.copy()
if bin_dir:
try:
p = str(Path(bin_dir).expanduser().resolve())
except OSError:
p = bin_dir
env["PATH"] = p + os.pathsep + env.get("PATH", "")
for line in self.build_env_extra_var.get().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
k, v = line.split("=", 1)
k = k.strip()
if k:
env[k] = v.strip()
return env
def _show_config_dialog(self):
c = self._resolved_theme_colors()
d = tk.Toplevel(self.root)
d.title("Configuration")
d.transient(self.root)
d.minsize(460, 420)
d.geometry("540x460")
outer = ttk.Frame(d, padding=12, style="CardInner.TFrame")
outer.pack(fill=tk.BOTH, expand=True)
outer.columnconfigure(0, weight=1)
ttk.Label(outer, text="Window theme", style="Card.TLabel").grid(
row=0, column=0, sticky="w", pady=(0, 4)
)
theme_row = ttk.Frame(outer)
theme_row.grid(row=1, column=0, sticky="ew", pady=(0, 10))
theme_draft = tk.StringVar(value=self.window_theme_display.get())
theme_cb = ttk.Combobox(
theme_row,
textvariable=theme_draft,
values=tuple(WINDOW_THEME_LABEL.values()),
state="readonly",
width=34,
)
theme_cb.pack(side=tk.LEFT)
clean_draft = tk.BooleanVar(value=self.clean_temp_before_build_var.get())
ttk.Checkbutton(
outer,
text="Clean .temp before build",
variable=clean_draft,
style="Card.TCheckbutton",
).grid(row=2, column=0, sticky="w", pady=(0, 10))
sep = ttk.Separator(outer, orient=tk.HORIZONTAL)
sep.grid(row=3, column=0, sticky="ew", pady=(0, 10))
ttk.Label(
outer,
text=(
"binarycreator uses the same environment as this app unless you set a bin folder "
"or extra variables below."
),
wraplength=500,
justify=tk.LEFT,
style="Card.TLabel",
).grid(row=4, column=0, sticky="ew", pady=(0, 8))
_bin_ph = "Optional: folder containing binarycreator.exe (empty = use system PATH)"
path_row = ttk.Frame(outer)
path_row.grid(row=5, column=0, sticky="ew", pady=(0, 4))
path_row.columnconfigure(1, weight=1)
ttk.Label(path_row, text="Qt IFW bin", style="Card.TLabel").grid(
row=0, column=0, sticky="nw", padx=(0, 6)
)
bin_entry = tk.Entry(
path_row,
relief="flat",
borderwidth=1,
highlightthickness=1,
font=_ui_font(9),
)
bin_entry.grid(row=0, column=1, sticky="ew", padx=(0, 6))
real_bin = self.qtifw_bin_var.get().strip()
if real_bin:
bin_entry.insert(0, real_bin)
bin_entry.config(fg=c["entry_fg"], bg=c["entry_bg"], highlightbackground=c["muted"])
else:
bin_entry.insert(0, _bin_ph)
bin_entry.config(fg=c["muted"], bg=c["entry_bg"], highlightbackground=c["muted"])
def _bin_is_placeholder() -> bool:
return bin_entry.get() == _bin_ph
def _bin_focus_in(_evt=None):
if _bin_is_placeholder():
bin_entry.delete(0, tk.END)
bin_entry.config(fg=c["entry_fg"])
def _bin_focus_out(_evt=None):
if not bin_entry.get().strip():
bin_entry.insert(0, _bin_ph)
bin_entry.config(fg=c["muted"])
bin_entry.bind("<FocusIn>", _bin_focus_in)
bin_entry.bind("<FocusOut>", _bin_focus_out)
def pick_bin():
p = filedialog.askdirectory(
parent=d,
title="Select Qt Installer Framework bin folder (contains binarycreator)",
)
if not p:
return
bin_entry.delete(0, tk.END)
bin_entry.insert(0, p)
bin_entry.config(fg=c["entry_fg"])
ttk.Button(
path_row,
text="Browse…",
command=pick_bin,
width=10,
style="Browse.TButton",
).grid(row=0, column=2, sticky="ns")
ttk.Label(outer, text="Extra environment (one KEY=value per line; # comments)", style="Card.TLabel").grid(
row=6, column=0, sticky="nw", pady=(0, 4)
)
text_frame = ttk.Frame(outer)
text_frame.grid(row=7, column=0, sticky="nsew", pady=(0, 8))
text_frame.rowconfigure(0, weight=1)
text_frame.columnconfigure(0, weight=1)
outer.rowconfigure(7, weight=1)
env_text = scrolledtext.ScrolledText(
text_frame,
wrap=tk.WORD,
height=8,
font=_ui_font(9),
relief="flat",
borderwidth=1,
)
env_text.grid(row=0, column=0, sticky="nsew")
env_text.insert("1.0", self.build_env_extra_var.get())
env_text.configure(
bg=c["entry_bg"],
fg=c["fg"],
insertbackground=c["fg"],
selectbackground=c["select_bg"],
selectforeground=c["select_fg"],
)
btn_row = ttk.Frame(outer)
btn_row.grid(row=8, column=0, sticky="e")
def close_d(release_grab: bool):
if release_grab:
try:
d.grab_release()
except tk.TclError:
pass
d.destroy()
def on_ok():
raw_bin = bin_entry.get().strip()
if not raw_bin or raw_bin == _bin_ph:
self.qtifw_bin_var.set("")
else:
self.qtifw_bin_var.set(raw_bin)
self.build_env_extra_var.set(env_text.get("1.0", "end-1c"))
self.window_theme_display.set(theme_draft.get())
self._on_window_theme_selected()
self.clean_temp_before_build_var.set(clean_draft.get())
self._persist_settings()
close_d(True)
def on_cancel():
close_d(True)
ttk.Button(
btn_row,
text="Cancel",
command=on_cancel,
style="Dialog.TButton",
).pack(side=tk.RIGHT, padx=(6, 0))
ttk.Button(btn_row, text="OK", command=on_ok, style="Dialog.TButton").pack(side=tk.RIGHT)
d.protocol("WM_DELETE_WINDOW", on_cancel)
d.update_idletasks()
self.root.update_idletasks()
dw, dh = d.winfo_reqwidth(), d.winfo_reqheight()
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
rw, rh = self.root.winfo_width(), self.root.winfo_height()
x = rx + max((rw - dw) // 2, 0)
y = ry + max((rh - dh) // 2, 0)
d.geometry(f"540x460+{x}+{y}")
d.grab_set()
d.focus_set()
theme_cb.focus_set()
def _on_close(self):
self._persist_settings()
try:
self.root.unbind_all("<MouseWheel>")
self.root.unbind_all("<Button-4>")
self.root.unbind_all("<Button-5>")
except tk.TclError:
pass
self.root.destroy()
def _resolved_theme_colors(self):
key = _effective_window_theme(self.window_theme_internal.get())
return THEME_DARK if key == "dark" else THEME_LIGHT
def _on_window_theme_selected(self, event=None):
key = WINDOW_THEME_FROM_LABEL.get(self.window_theme_display.get(), "system")
self.window_theme_internal.set(key)
self._apply_window_theme()
self._persist_settings()
def _apply_window_theme(self):
c = self._resolved_theme_colors()
self.root.configure(bg=c["bg"])
f = _ui_font
card = c["card_bg"]
hdr = c["header_bg"]
self.style.configure("TFrame", background=c["bg"])
self.style.configure("Canvas.TFrame", background=c["bg"])
self.style.configure("Header.TFrame", background=hdr)
self.style.configure("Card.TLabelframe", background=card, relief="flat")
self.style.configure(
"Card.TLabelframe.Label",
background=card,
foreground=c["fg"],
font=f(8, True),
)
self.style.configure("CardInner.TFrame", background=card)
self.style.configure("Card.TLabel", background=card, foreground=c["fg"], font=f(8))
self.style.configure("CardMuted.TLabel", background=card, foreground=c["muted"], font=f(8))
self.style.configure("HeaderTitle.TLabel", background=hdr, foreground=c["fg"], font=f(13, True))
self.style.configure("HeaderSub.TLabel", background=hdr, foreground=c["muted"], font=f(8))
self.style.configure("HeaderCtl.TLabel", background=hdr, foreground=c["muted"], font=f(8))
self.style.configure("TButton", background=card, foreground=c["fg"], font=f(8))
self.style.map(
"TButton",
background=[("active", c["entry_bg"])],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"HeaderHelp.TButton",
background=hdr,
foreground=c["fg"],
font=f(8),
# Match TCombobox vertical padding (3, 1) so header controls align in height.
padding=(10, 1),
)
self.style.map(
"HeaderHelp.TButton",
background=[("active", c["entry_bg"])],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"Accent.TButton",
background=c["accent"],
foreground="#ffffff",
font=f(8, True),
padding=(8, 3),
)
self.style.map(
"Accent.TButton",
background=[("active", c["accent_hover"]), ("disabled", c["muted"])],
foreground=[("disabled", "#e2e8f0")],
)
self.style.configure(
"TEntry",
fieldbackground=c["entry_bg"],
foreground=c["entry_fg"],
insertcolor=c["entry_fg"],
padding=(4, 2),
)
self.style.configure(
"Browse.TButton",
background=c["entry_bg"],
foreground=c["entry_fg"],
font=f(8),
padding=(4, 2),
)
self.style.map(
"Browse.TButton",
background=[("active", c["card_bg"]), ("disabled", c["card_bg"])],
foreground=[("disabled", c["muted"])],
)
# Config dialog: match main-window control height (same vertical padding as Browse.TButton).
self.style.configure(
"Dialog.TButton",
background=card,
foreground=c["fg"],
font=f(8),
padding=(4, 2),
)
self.style.map(
"Dialog.TButton",
background=[("active", c["entry_bg"])],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"TCombobox",
fieldbackground=c["entry_bg"],
background=c["entry_bg"],
foreground=c["entry_fg"],
arrowcolor=c["entry_fg"],
padding=(3, 1),
)
self.style.map(
"TCombobox",
fieldbackground=[("readonly", c["entry_bg"])],
selectbackground=[("readonly", c["entry_bg"])],
selectforeground=[("readonly", c["entry_fg"])],
)
self.style.configure(
"Card.TCheckbutton",
background=card,
foreground=c["fg"],
font=f(8),
)
self.style.map(
"Card.TCheckbutton",
background=[("active", card)],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"Canvas.TCheckbutton",
background=c["bg"],
foreground=c["fg"],
font=f(8),
)
self.style.map(
"Canvas.TCheckbutton",
background=[("active", c["bg"])],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"Log.TLabelframe",
background=card,
relief="flat",
)
self.style.configure(
"Log.TLabelframe.Label",
background=card,
foreground=c["fg"],
font=f(8, True),
)
if hasattr(self, "log_text"):
self.log_text.configure(
bg=c["log_bg"],
fg=c["log_fg"],
insertbackground=c["log_cursor"],
selectbackground=c["select_bg"],
selectforeground=c["select_fg"],
font=("Consolas", 8) if sys.platform == "win32" else ("Monospace", 8),
)
if hasattr(self, "_header_sep"):
self._header_sep.configure(bg=c["border"])
if hasattr(self, "_scroll_canvas_left"):
self._scroll_canvas_left.configure(background=c["bg"])
self._scroll_canvas_right.configure(background=c["bg"])
def _scroll_canvas_at_pointer(self, event) -> Optional[tk.Canvas]:
w = self.root.winfo_containing(event.x_root, event.y_root)
while w is not None:
if w is self._scroll_canvas_left:
return self._scroll_canvas_left
if w is self._scroll_canvas_right:
return self._scroll_canvas_right
w = w.master
return None
def _on_body_mousewheel(self, event):
canvas = self._scroll_canvas_at_pointer(event)
if not canvas:
return
delta = getattr(event, "delta", 0)
if not delta:
return
if sys.platform == "win32":
canvas.yview_scroll(int(-delta / 120), "units")
else:
canvas.yview_scroll(
int(-delta / 120) if abs(delta) >= 120 else (-1 if delta > 0 else 1),
"units",
)
def _on_linux_mousewheel(self, event):
canvas = self._scroll_canvas_at_pointer(event)
if not canvas:
return
if event.num == 4:
canvas.yview_scroll(-3, "units")
elif event.num == 5:
canvas.yview_scroll(3, "units")
def _make_scrollable_column(self, parent, grid_column: int, padx_tuple, side_key: str) -> ttk.Frame:
ccolors = self._resolved_theme_colors()
outer = ttk.Frame(parent, style="Canvas.TFrame")
outer.grid(row=0, column=grid_column, sticky="nsew", padx=padx_tuple)
outer.rowconfigure(0, weight=1)
outer.columnconfigure(0, weight=1)
canvas = tk.Canvas(
outer,
highlightthickness=0,
borderwidth=0,
background=ccolors["bg"],
)
vsb = ttk.Scrollbar(outer, orient=tk.VERTICAL, command=canvas.yview)
canvas.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
vsb.grid_remove()
inner = ttk.Frame(canvas, style="Canvas.TFrame")
inner_win = canvas.create_window((0, 0), window=inner, anchor="nw")
def sync_scrollbar_visibility():
canvas.update_idletasks()
bbox = canvas.bbox("all")
ch = canvas.winfo_height()
if not bbox or ch <= 1:
if vsb.winfo_ismapped():
vsb.grid_remove()
canvas.configure(yscrollcommand=lambda *_a: None)
return
_, y1, _, y2 = bbox
content_h = y2 - y1
if content_h > ch + 2:
canvas.configure(yscrollcommand=vsb.set)
if not vsb.winfo_ismapped():
vsb.grid(row=0, column=1, sticky="ns")
else:
if vsb.winfo_ismapped():
vsb.grid_remove()
canvas.configure(yscrollcommand=lambda *_a: None)
canvas.yview_moveto(0)
def on_inner_configure(_evt=None):
canvas.configure(scrollregion=canvas.bbox("all"))
sync_scrollbar_visibility()
def on_canvas_configure(evt):
canvas.itemconfigure(inner_win, width=max(evt.width, 1))
sync_scrollbar_visibility()
inner.bind("<Configure>", on_inner_configure)
canvas.bind("<Configure>", on_canvas_configure)
canvas.after_idle(sync_scrollbar_visibility)
if side_key == "left":
self._scroll_canvas_left = canvas
else:
self._scroll_canvas_right = canvas
return inner
def _pick_file(self, var: tk.StringVar, patterns):
path = filedialog.askopenfilename(filetypes=patterns)
if path:
var.set(path)
def _sync_license_inputs_state(self, *_args):
if not hasattr(self, "_license_path_entry"):
return
on = self.license_enabled_var.get()
est = "normal" if on else "disabled"
self._license_path_entry.configure(state=est)
self._license_title_entry.configure(state=est)
self._license_path_btn.configure(state=est)
def _sync_run_after_install_state(self, *_args):
if not hasattr(self, "_run_after_desc_entry"):
return
on = self.run_after_install_var.get()
self._run_after_desc_entry.configure(state="normal" if on else "disabled")
def create_widgets(self):
self.root.columnconfigure(0, weight=1)
self.root.rowconfigure(2, weight=1)
header = ttk.Frame(self.root, style="Header.TFrame", padding=(10, 5, 10, 4))
header.grid(row=0, column=0, sticky="ew")
header.columnconfigure(0, weight=1)
title_block = ttk.Frame(header, style="Header.TFrame")
title_block.grid(row=0, column=0, rowspan=2, sticky="nw")
ttk.Label(title_block, text="Qt IFW Generator", style="HeaderTitle.TLabel").pack(anchor="w")
ttk.Label(
title_block,
text="Qt Installer Framework — offline package layout & .exe",
style="HeaderSub.TLabel",
).pack(anchor="w", pady=(1, 0))
ctl = ttk.Frame(header, style="Header.TFrame")
ctl.grid(row=0, column=1, rowspan=2, sticky="ne", padx=(10, 0))
row_a = ttk.Frame(ctl, style="Header.TFrame")
row_a.pack(anchor="e", pady=(0, 2))
ttk.Button(
row_a,
text="Help",
command=self._show_predefined_variables_help,
style="HeaderHelp.TButton",
).pack(side=tk.LEFT, padx=(10, 0))
ttk.Button(
row_a,
text="Export…",
command=self._export_settings,
style="HeaderHelp.TButton",
).pack(side=tk.LEFT, padx=(6, 0))
ttk.Button(
row_a,
text="Import…",
command=self._import_settings,
style="HeaderHelp.TButton",
).pack(side=tk.LEFT, padx=(6, 0))
ttk.Button(
row_a,
text="Config…",
command=self._show_config_dialog,
style="HeaderHelp.TButton",
).pack(side=tk.LEFT, padx=(6, 0))
self._header_sep = tk.Frame(self.root, height=1, relief="flat")
self._header_sep.grid(row=1, column=0, sticky="ew")
body = ttk.Frame(self.root, padding=(8, 5, 8, 6))
body.grid(row=2, column=0, sticky="nsew")
body.rowconfigure(0, weight=1)
body.columnconfigure(0, weight=1)
body.columnconfigure(1, weight=1)
left_col = self._make_scrollable_column(body, 0, (0, 5), "left")
right_col = self._make_scrollable_column(body, 1, (5, 0), "right")
self.root.bind_all("<MouseWheel>", self._on_body_mousewheel)
if sys.platform == "linux":
self.root.bind_all("<Button-4>", self._on_linux_mousewheel)
self.root.bind_all("<Button-5>", self._on_linux_mousewheel)
png_ft = [("PNG", "*.png"), ("All", "*.*")]
ico_ft = [("Icon", "*.ico"), ("All", "*.*")]
license_ft = [("Text", "*.txt"), ("HTML", "*.html;*.htm" if sys.platform == "win32" else "*.html"), ("All", "*.*")]
lf_installer = ttk.LabelFrame(left_col, text=" Installer ", style="Card.TLabelframe", padding=(6, 4))
lf_installer.pack(fill=tk.X, anchor="nw")
form = ttk.Frame(lf_installer, style="CardInner.TFrame")
form.pack(fill=tk.X)
form.columnconfigure(1, weight=1)
def _field(parent, r, label, var):
ttk.Label(parent, text=label, style="Card.TLabel").grid(row=r, column=0, sticky="nw", padx=(0, 6), pady=2)
ttk.Entry(parent, textvariable=var).grid(row=r, column=1, sticky="ew", pady=2)
def _asset_row(parent, r, label, var, patterns, w=22):
ttk.Label(parent, text=label, style="Card.TLabel").grid(row=r, column=0, sticky="nw", padx=(0, 6), pady=2)
ttk.Entry(parent, textvariable=var, width=w).grid(row=r, column=1, sticky="ew", pady=2)
ttk.Button(
parent,
text="…",
width=2,
style="Browse.TButton",
command=lambda v=var, p=patterns: self._pick_file(v, p),
).grid(row=r, column=2, sticky="ns", padx=(4, 0), pady=2)
_field(form, 0, "Name", self.name_var)
_field(form, 1, "Version", self.version_var)
_field(form, 2, "Publisher", self.publisher_var)
_field(form, 3, "Title bar", self.title_var)
_field(form, 4, "Target folder", self.target_var)
ttk.Label(form, text="Override target folder", style="Card.TLabel").grid(
row=5, column=0, sticky="nw", padx=(0, 6), pady=2
)
ttk.Checkbutton(
form,
text="Allow install if target folder is not empty",
variable=self.allow_nonempty_target_var,
style="Card.TCheckbutton",
).grid(row=5, column=1, columnspan=2, sticky="w", pady=2)
_field(form, 6, "Start Menu folder", self.startmenu_dir_var)
ttk.Label(form, text="Wizard style", style="Card.TLabel").grid(
row=7, column=0, sticky="nw", padx=(0, 6), pady=2
)
ttk.Combobox(
form,
textvariable=self.installer_ui_var,
values=INSTALLER_UI_LABELS,
state="readonly",
).grid(row=7, column=1, sticky="ew", pady=2)
ttk.Label(form, text="Enable license page", style="Card.TLabel").grid(
row=8, column=0, sticky="nw", padx=(0, 6), pady=(4, 2)
)
ttk.Checkbutton(
form,
text="User must accept license before installing",
variable=self.license_enabled_var,
style="Card.TCheckbutton",
).grid(row=8, column=1, sticky="w", pady=(4, 2))
ttk.Label(form, text="License file (txt/html)", style="Card.TLabel").grid(
row=9, column=0, sticky="nw", padx=(0, 6), pady=2
)
self._license_path_entry = ttk.Entry(form, textvariable=self.license_path_var, width=22)
self._license_path_entry.grid(row=9, column=1, sticky="ew", pady=2)
self._license_path_btn = ttk.Button(
form,
text="…",
width=2,
style="Browse.TButton",
command=lambda: self._pick_file(self.license_path_var, license_ft),
)
self._license_path_btn.grid(row=9, column=2, sticky="ns", padx=(4, 0), pady=2)
ttk.Label(form, text="License page title", style="Card.TLabel").grid(
row=10, column=0, sticky="nw", padx=(0, 6), pady=2
)
self._license_title_entry = ttk.Entry(form, textvariable=self.license_title_var)
self._license_title_entry.grid(row=10, column=1, columnspan=2, sticky="ew", pady=2)
self._sync_license_inputs_state()
_asset_row(form, 11, "Banner (PNG:500x70)", self.banner_path_var, png_ft)
_asset_row(form, 12, "Logo (PNG:100x100)", self.logo_path_var, png_ft)
_asset_row(form, 13, "Watermark (PNG:300)", self.watermark_path_var, png_ft)
_asset_row(form, 14, "Window icon (PNG:16x16)", self.winicon_path_var, png_ft)
_asset_row(form, 15, "App icon (.ico)", self.appicon_path_var, ico_ft)
ttk.Label(form, text="Product URL", style="Card.TLabel").grid(
row=16, column=0, sticky="nw", padx=(0, 6), pady=2
)
ttk.Entry(form, textvariable=self.product_url_var).grid(
row=16, column=1, columnspan=2, sticky="ew", pady=2
)
lf_pkg = ttk.LabelFrame(right_col, text=" Package & source ", style="Card.TLabelframe", padding=(6, 4))
lf_pkg.pack(fill=tk.X, pady=(0, 4))
pkg_inner = ttk.Frame(lf_pkg, style="CardInner.TFrame")
pkg_inner.pack(fill=tk.X)
pkg_inner.columnconfigure(1, weight=1)
def _pkg_path_row(parent, r, label, var):
ttk.Label(parent, text=label, style="Card.TLabel").grid(
row=r, column=0, sticky="nw", padx=(0, 6), pady=2
)
ttk.Entry(parent, textvariable=var).grid(row=r, column=1, sticky="ew", pady=2)
ttk.Button(
parent,
text="…",
width=2,
style="Browse.TButton",
command=self.choose_app_dir,
).grid(row=r, column=2, sticky="ns", padx=(4, 0), pady=2)
ttk.Label(pkg_inner, text="Package id", style="Card.TLabel").grid(row=0, column=0, sticky="nw", padx=(0, 6), pady=2)
ttk.Entry(pkg_inner, textvariable=self.pkg_name_var).grid(row=0, column=1, sticky="ew", pady=2)
ttk.Label(pkg_inner, text="Display name", style="Card.TLabel").grid(row=1, column=0, sticky="nw", padx=(0, 6), pady=2)
ttk.Entry(pkg_inner, textvariable=self.pkg_display_var).grid(row=1, column=1, sticky="ew", pady=2)
_pkg_path_row(pkg_inner, 2, "Application folder", self.app_source_dir_var)
lf_sc = ttk.LabelFrame(right_col, text=" Shortcut ", style="Card.TLabelframe", padding=(6, 4))
lf_sc.pack(fill=tk.X, pady=(0, 4))
sc = ttk.Frame(lf_sc, style="CardInner.TFrame")
sc.pack(fill=tk.X)
sc.columnconfigure(1, weight=1)
_field(sc, 0, "Shortcut name (.lnk)", self.shortcut_name_var)
_field(sc, 1, "Target (exe)", self.shortcut_target_var)
ttk.Label(sc, text="Icon (optional)", style="Card.TLabel").grid(row=2, column=0, sticky="nw", padx=(0, 6), pady=2)
ttk.Entry(sc, textvariable=self.shortcut_icon_var).grid(row=2, column=1, sticky="ew", pady=2)
ttk.Button(
sc,
text="…",
width=2,
style="Browse.TButton",
command=lambda: self._pick_file(self.shortcut_icon_var, ico_ft + [("EXE", "*.exe"), ("All", "*.*")]),
).grid(row=2, column=2, sticky="ns", padx=(4, 0), pady=2)
ttk.Label(sc, text="Location", style="Card.TLabel").grid(
row=3, column=0, sticky="nw", padx=(0, 6), pady=(4, 0)
)
cb_row = ttk.Frame(sc, style="CardInner.TFrame")
cb_row.grid(row=3, column=1, columnspan=2, sticky="w", pady=(4, 0))
ttk.Checkbutton(
cb_row,
text="Desktop",
variable=self.shortcut_desktop_var,
style="Card.TCheckbutton",
).pack(side=tk.LEFT, padx=(0, 12))
ttk.Checkbutton(
cb_row,
text="Start menu",
variable=self.shortcut_startmenu_var,
style="Card.TCheckbutton",
).pack(side=tk.LEFT)
ttk.Label(sc, text="Run after install", style="Card.TLabel").grid(
row=4, column=0, sticky="nw", padx=(0, 6), pady=(8, 2)
)
ttk.Checkbutton(
sc,
text="",
variable=self.run_after_install_var,
style="Card.TCheckbutton",
).grid(row=4, column=1, sticky="w", pady=(8, 2))
ttk.Label(sc, text="Run checkbox label", style="Card.TLabel").grid(
row=5, column=0, sticky="nw", padx=(0, 6), pady=2
)
self._run_after_desc_entry = ttk.Entry(sc, textvariable=self.run_after_install_desc_var)
self._run_after_desc_entry.grid(row=5, column=1, columnspan=2, sticky="ew", pady=2)
self._sync_run_after_install_state()
actions = ttk.Frame(body, style="Canvas.TFrame")
actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(6, 4))
self.build_btn = ttk.Button(
actions,
text="Build installer (.exe)",
style="Accent.TButton",
command=self.start_build_installer,
)
self.build_btn.pack(side=tk.LEFT)
lf_log = ttk.LabelFrame(body, text=" Log ", style="Log.TLabelframe", padding=(4, 3))
lf_log.grid(row=2, column=0, columnspan=2, sticky="ew")
self.log_text = scrolledtext.ScrolledText(lf_log, height=7, relief="flat", borderwidth=0, highlightthickness=0)
self.log_text.pack(fill=tk.X, expand=False)
self.main_frame = body
def log(self, msg):
self.log_text.insert(tk.END, f"[{time.strftime('%H:%M:%S')}] {msg}\n")
self.log_text.see(tk.END)
self.root.update_idletasks()
def _show_predefined_variables_help(self):
win_attr = "_predefined_vars_help_win"
prev = getattr(self, win_attr, None)
if prev is not None and prev.winfo_exists():
prev.lift()
prev.focus_set()
return
c = self._resolved_theme_colors()
f = _ui_font
self.style.configure(
"Help.Treeview",
background=c["entry_bg"],
fieldbackground=c["entry_bg"],
foreground=c["fg"],
rowheight=22,
font=f(9),
)
self.style.configure(
"Help.Treeview.Heading",
background=c["card_bg"],
foreground=c["fg"],
font=f(8, True),
)
self.style.map(
"Help.Treeview",
background=[("selected", c["select_bg"])],
foreground=[("selected", c["select_fg"])],
)
d = tk.Toplevel(self.root)
setattr(self, win_attr, d)
d.title("Predefined variables — Qt IFW")
d.transient(self.root)
d.minsize(520, 360)
d.geometry("720x480")
outer = ttk.Frame(d, padding=12)
outer.pack(fill=tk.BOTH, expand=True)
outer.rowconfigure(1, weight=1)
outer.columnconfigure(0, weight=1)
intro = (
"Use these symbols in component scripts (QJSEngine). Read values with "
'installer.value("Name") or embed them in operation strings as @Name@. '
f"Full reference: {IFW_PREDEFINED_VARS_DOC_URL}"
)
ttk.Label(outer, text=intro, wraplength=660, justify=tk.LEFT).grid(
row=0, column=0, sticky="ew", pady=(0, 8)
)
table_frame = ttk.Frame(outer)
table_frame.grid(row=1, column=0, sticky="nsew", pady=(0, 8))
table_frame.rowconfigure(0, weight=1)
table_frame.columnconfigure(0, weight=1)
cols = ("symbol", "description")
tree = ttk.Treeview(
table_frame,
columns=cols,
show="headings",
selectmode="browse",
style="Help.Treeview",
height=16,
)
tree.heading("symbol", text="Symbol", anchor="w")
tree.heading("description", text="Description", anchor="w")
tree.column("symbol", width=200, minwidth=120, stretch=False, anchor="w")
tree.column("description", width=480, minwidth=240, stretch=True, anchor="w")
vsb = ttk.Scrollbar(table_frame, orient="vertical", command=tree.yview)
hsb = ttk.Scrollbar(table_frame, orient="horizontal", command=tree.xview)
tree.configure(yscrollcommand=vsb.set, xscrollcommand=hsb.set)
tree.grid(row=0, column=0, sticky="nsew")
vsb.grid(row=0, column=1, sticky="ns")
hsb.grid(row=1, column=0, sticky="ew")
for name, desc in IFW_PREDEFINED_VARIABLES:
tree.insert("", tk.END, values=(name, desc))
btn_row = ttk.Frame(outer)
btn_row.grid(row=2, column=0, sticky="e")
def open_docs():
webbrowser.open(IFW_PREDEFINED_VARS_DOC_URL)
def close_help():
setattr(self, win_attr, None)
d.destroy()
ttk.Button(btn_row, text="Close", command=close_help).pack(side=tk.RIGHT)
ttk.Button(btn_row, text="Open documentation…", command=open_docs).pack(
side=tk.RIGHT, padx=(0, 16)
)
d.protocol("WM_DELETE_WINDOW", close_help)
d.update_idletasks()
self.root.update_idletasks()
dw, dh = d.winfo_reqwidth(), d.winfo_reqheight()
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
rw, rh = self.root.winfo_width(), self.root.winfo_height()
x = rx + max((rw - dw) // 2, 0)
y = ry + max((rh - dh) // 2, 0)
d.geometry(f"720x480+{x}+{y}")
def _show_build_success_dialog(self, installer_path: Path):
p = installer_path.expanduser().resolve()
exists = p.is_file()
c = self._resolved_theme_colors()
f = _ui_font
pad = (10, 5)
self.style.configure(
"SuccessDlg.TButton",
background=c["card_bg"],
foreground=c["fg"],
font=f(8),
padding=pad,
)
self.style.map(
"SuccessDlg.TButton",
background=[("active", c["entry_bg"])],
foreground=[("disabled", c["muted"])],
)
self.style.configure(
"SuccessDlgAccent.TButton",
background=c["accent"],
foreground="#ffffff",
font=f(8),
padding=pad,
)
self.style.map(
"SuccessDlgAccent.TButton",
background=[("active", c["accent_hover"]), ("disabled", c["muted"])],
foreground=[("disabled", "#e2e8f0")],
)
d = tk.Toplevel(self.root)
d.title("Success")
d.transient(self.root)
d.resizable(False, False)
outer = ttk.Frame(d, padding=14)
outer.pack(fill=tk.BOTH, expand=True)
msg = f"Installer created:\n{os.path.normpath(str(p))}"
ttk.Label(outer, text=msg, justify=tk.LEFT).pack(anchor="w", pady=(0, 12))
btn_row = ttk.Frame(outer)
btn_row.pack(anchor="e")
def close_d():
d.grab_release()
d.destroy()
def reveal_installer():
if not exists:
return
try:
_reveal_path_in_file_manager(p)
except OSError as e:
messagebox.showerror("Could not reveal", str(e), parent=d)
def run_installer():
if not exists:
return
try:
if sys.platform == "win32":
os.startfile(os.path.normpath(str(p)))
elif sys.platform == "darwin":
subprocess.Popen(["open", str(p)])
else:
subprocess.Popen([str(p)], cwd=str(p.parent))
except OSError as e:
messagebox.showerror("Could not run", str(e), parent=d)
return
close_d()
ttk.Button(btn_row, text="Close", command=close_d, style="SuccessDlg.TButton").pack(
side=tk.LEFT, padx=(0, 6)
)
reveal_btn = ttk.Button(
btn_row,
text="Reveal installer",
command=reveal_installer,
style="SuccessDlg.TButton",
)
reveal_btn.pack(side=tk.LEFT, padx=(0, 6))
run_btn = ttk.Button(
btn_row,
text="Run installer",
command=run_installer,
style="SuccessDlgAccent.TButton",
)
run_btn.pack(side=tk.LEFT)
if not exists:
reveal_btn.state(["disabled"])
run_btn.state(["disabled"])
d.protocol("WM_DELETE_WINDOW", close_d)
d.update_idletasks()
self.root.update_idletasks()
dw, dh = d.winfo_reqwidth(), d.winfo_reqheight()
rx, ry = self.root.winfo_rootx(), self.root.winfo_rooty()
rw, rh = self.root.winfo_width(), self.root.winfo_height()
x = rx + max((rw - dw) // 2, 0)
y = ry + max((rh - dh) // 2, 0)
d.geometry(f"{dw}x{dh}+{x}+{y}")
d.grab_set()
d.focus_set()
def choose_app_dir(self):
path = filedialog.askdirectory(title="Select folder containing the application (.exe)")
if path:
self.app_source_dir_var.set(path)
def _gather_config_xml_extras(self, config_dir: Path):
lines = []
warns = []
url = self.product_url_var.get().strip()
if url:
lines.append(f" <ProductUrl>{_xml_escape(url)}</ProductUrl>")
def try_img(var, tag):
raw = var.get().strip()
if not raw:
return
fn = _copy_asset_to_config(raw, config_dir)
if fn:
lines.append(f" <{tag}>{_xml_escape(fn)}</{tag}>")
else:
warns.append(f"⚠ Could not copy (skipping {tag}): {raw}")
try_img(self.banner_path_var, "Banner")
try_img(self.logo_path_var, "Logo")
try_img(self.watermark_path_var, "Watermark")
wr = self.winicon_path_var.get().strip()
if wr:
wfn = _copy_asset_to_config(wr, config_dir)
if wfn:
lines.append(f" <InstallerWindowIcon>{_xml_escape(wfn)}</InstallerWindowIcon>")
else:
warns.append(f"⚠ Could not copy InstallerWindowIcon: {wr}")
ar = self.appicon_path_var.get().strip()
if ar:
afn = _copy_asset_to_config(ar, config_dir)
if afn:
base = _installer_app_icon_basename_for_xml(afn)
lines.append(f" <InstallerApplicationIcon>{_xml_escape(base)}</InstallerApplicationIcon>")
else:
warns.append(f"⚠ Could not copy InstallerApplicationIcon: {ar}")
return lines, warns
def _resolved_shortcut_exe_path(self) -> str:
t = self.shortcut_target_var.get().strip()
if not t:
t = "main.exe"
if t.startswith("@"):
return t.replace("\\", "/")
return "@TargetDir@/" + t.replace("\\", "/").lstrip("/")
def _resolved_shortcut_icon_path(self) -> str:
ico = self.shortcut_icon_var.get().strip()
if not ico:
return self._resolved_shortcut_exe_path()
if ico.startswith("@"):
return ico.replace("\\", "/")
return "@TargetDir@/" + ico.replace("\\", "/").lstrip("/")
def _build_installscript_qs(self) -> str:
if not self.shortcut_desktop_var.get() and not self.shortcut_startmenu_var.get():
return (
"function Component()\n{\n}\n\n"
"Component.prototype.createOperations = function()\n{\n"
" component.createOperations();\n};\n"
)
exe = self._resolved_shortcut_exe_path()
icn = self._resolved_shortcut_icon_path()
link = (self.shortcut_name_var.get().strip() or "App").replace("\n", " ").replace("\r", "")
def jl(s):
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
parts = [
"function Component()",
"{",
"}",
"",
"Component.prototype.createOperations = function()",
"{",
" component.createOperations();",
f" var exePath = {jl(exe)};",
f" var iconPath = {jl(icn)};",
f" var linkName = {jl(link)};",
]
if self.shortcut_desktop_var.get():
parts.append(
' component.addOperation("CreateShortcut", exePath,'
' "@DesktopDir@/" + linkName + ".lnk",'
' "workingDirectory=@TargetDir@",'
' "iconPath=" + iconPath);'
)
if self.shortcut_startmenu_var.get():
parts.append(
' component.addOperation("CreateShortcut", exePath,'
' "@StartMenuDir@/" + linkName + ".lnk",'
' "workingDirectory=@TargetDir@",'
' "iconPath=" + iconPath);'
)
parts.append("};")
parts.append("")
return "\n".join(parts)
def start_build_installer(self):
if self.is_running:
return
src = self.app_source_dir_var.get().strip()
if not src or not _resolve_user_path(src).is_dir():
messagebox.showwarning(
"Warning",
"Please enter or browse to a valid application folder path.",
)
return
if self.license_enabled_var.get():
lp = self.license_path_var.get().strip()
if not lp:
messagebox.showwarning(
"Warning",
"Licenses are enabled — please enter or browse to a license file path.",
)
return
if not _resolve_user_path(lp).is_file():
messagebox.showwarning(
"Warning",
"The license file does not exist or is not a regular file.",
)
return
self.is_running = True
self.build_btn.config(state="disabled")
self.log("Build installer: preparing Qt IFW package…")
threading.Thread(target=self._build_installer_thread, daemon=True).start()
def _build_installer_thread(self):
output_dir = BUILD_WORK_DIR
try:
if self.clean_temp_before_build_var.get():
if output_dir.exists():
self.log("Removing .temp (clean before build)…")
try:
shutil.rmtree(output_dir)
except OSError as e:
self.log(f"❌ Could not remove .temp: {e}")
self.root.after(
0,
lambda err=str(e): messagebox.showerror(
"Clean temp",
f"Could not remove {output_dir}:\n{err}",
),
)
return
config_dir = output_dir / "config"
pkg_dir = self.pkg_name_var.get()
meta_dir = output_dir / "packages" / pkg_dir / "meta"
data_dir = output_dir / "packages" / pkg_dir / "data"
for d in [config_dir, meta_dir, data_dir]:
d.mkdir(parents=True, exist_ok=True)
self.log("Copying application into package…")
if data_dir.exists():
shutil.rmtree(data_dir)
shutil.copytree(
_resolve_user_path(self.app_source_dir_var.get().strip()),
data_dir,
dirs_exist_ok=True,
)
extra_lines, media_warns = self._gather_config_xml_extras(config_dir)
for w in media_warns:
self.root.after(0, lambda msg=w: self.log(msg))
if self.run_after_install_var.get():
rp = self._resolved_shortcut_exe_path()
extra_lines.append(f" <RunProgram>{_xml_escape(rp)}</RunProgram>")
rdesc = self.run_after_install_desc_var.get().strip()
if rdesc:
extra_lines.append(
f" <RunProgramDescription>{_xml_escape(rdesc)}</RunProgramDescription>"
)
wizard_line = INSTALLER_UI_LINES.get(self.installer_ui_var.get(), "")
if self.allow_nonempty_target_var.get():
extra_lines.insert(0, " <RemoveTargetDir>false</RemoveTargetDir>")
extra_block = ("\n" + "\n".join(extra_lines)) if extra_lines else ""
config_xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<Installer>
<Name>{_xml_escape(self.name_var.get())}</Name>
<Version>{_xml_escape(self.version_var.get())}</Version>
<Title>{_xml_escape(self.title_var.get())}</Title>
<Publisher>{_xml_escape(self.publisher_var.get())}</Publisher>
<StartMenuDir>{_xml_escape(self.startmenu_dir_var.get())}</StartMenuDir>
<TargetDir>{_xml_escape(self.target_var.get())}</TargetDir>{extra_block}
{wizard_line}</Installer>
"""
(config_dir / "config.xml").write_text(config_xml, encoding="utf-8")
pkg_id = pkg_dir.strip()
release_date = time.strftime("%Y-%m-%d")
licenses_block = ""
if self.license_enabled_var.get():
lfn = _copy_file_into_dest_dir(self.license_path_var.get().strip(), meta_dir)
if not lfn:
self.log("❌ Could not copy license file into meta/.")
self.root.after(
0,
lambda: messagebox.showerror(
"License",
"Could not copy the license file. Check the path and read permissions.",
),
)
return
lic_title = self.license_title_var.get().strip() or "License Agreement"
licenses_block = (
" <Licenses>\n"
f' <License name="{_xml_escape(lic_title)}" file="{_xml_escape(lfn)}"/>\n'
" </Licenses>\n"
)
(meta_dir / "package.xml").write_text(f"""<?xml version="1.0" encoding="UTF-8"?>
<Package>
<Name>{pkg_id}</Name>
<DisplayName>{self.pkg_display_var.get()}</DisplayName>
<Description>Main application package</Description>
<Version>{self.version_var.get()}</Version>
<ReleaseDate>{release_date}</ReleaseDate>
<Default>true</Default>
<ForcedInstallation>true</ForcedInstallation>
{licenses_block} <Script>installscript.qs</Script>
</Package>
""", encoding="utf-8")
installscript_content = self._build_installscript_qs()
(meta_dir / "installscript.qs").write_text(installscript_content, encoding="utf-8")
ctrl = config_dir / "controller.qs"
if ctrl.exists():
ctrl.unlink()
self.output_dir = output_dir
self.log("Running binarycreator…")
installer_name = f"{self.name_var.get()}-{self.version_var.get()}-installer.exe"
installer_dist = INSTALLER_DIST_DIR
installer_dist.mkdir(parents=True, exist_ok=True)
try:
result = subprocess.run(
[
"binarycreator",
"-c", str(output_dir / "config/config.xml"),
"-p", str(output_dir / "packages"),
"--offline-only",
installer_name,
],
capture_output=True,
text=True,
timeout=600,
env=self._build_subprocess_env(),
cwd=str(installer_dist),
)
except FileNotFoundError:
self.log("❌ binarycreator not found. Set Qt IFW bin in Config… or add it to PATH.")
self.root.after(
0,
lambda: messagebox.showerror(
"binarycreator missing",
"binarycreator was not found.\n"
"Open Config… and set “Qt IFW bin”, or add the Qt Installer Framework bin folder to PATH.",
),
)
return
if result.returncode != 0:
self.log("❌ binarycreator error:\n" + (result.stderr or result.stdout or ""))
err_txt = (result.stderr or result.stdout or "binarycreator failed").strip()
self.root.after(0, lambda t=err_txt: messagebox.showerror("Build failed", t[:800]))
return
self.log(f"✅ Done: dist/{installer_name}")
inst_path = (installer_dist / installer_name).resolve()
self.root.after(0, lambda ip=inst_path: self._show_build_success_dialog(ip))
except Exception as e:
self.log(f"❌ Error: {e}")
self.root.after(0, lambda err=str(e): messagebox.showerror("Error", err))
finally:
self.is_running = False
self.root.after(0, lambda: self.build_btn.config(state="normal"))
if __name__ == "__main__":
if "jinja2" not in sys.modules:
try:
import jinja2
except ImportError:
print("Installing jinja2...")
os.system(sys.executable + " -m pip install jinja2")
app = QtIFWGui()
app.root.mainloop()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment