Created
April 10, 2026 08:06
-
-
Save whileloop99/03c7fa0a5156a4953b21c203ed094142 to your computer and use it in GitHub Desktop.
QT Installer Framework GUI
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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("&", "&") | |
| .replace("<", "<") | |
| .replace(">", ">") | |
| .replace('"', """) | |
| ) | |
| 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