Source code for editor.ui.export_dialog

import os
import sys
import subprocess
import webbrowser
import platform
import threading
from PyQt6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDialog,
    QFileDialog,
    QFormLayout,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QMessageBox,
    QProgressBar,
    QPushButton,
    QVBoxLayout
)
from PyQt6.QtCore import QTimer
from core.logger import get_logger
from export.builder import WebExporter, DesktopExporter, MobileExporter, ServerExporter, ExportCancelled


[docs] class ExportDialog(QDialog): def __init__(self, project_path: str, parent=None): super().__init__(parent) self.project_path = project_path self._logger = get_logger("editor.export") self.setWindowTitle("Export Project") self.setMinimumWidth(520) self._exporter_by_name = { "Web": WebExporter, "Desktop": DesktopExporter, "Mobile": MobileExporter, "Server": ServerExporter } self._server_process = None self._build_ui() def _build_ui(self): main_layout = QVBoxLayout(self) form = QFormLayout() self.target_combo = QComboBox() self.target_combo.addItems(list(self._exporter_by_name.keys())) self.target_combo.currentTextChanged.connect(self._on_target_changed) form.addRow("Target:", self.target_combo) self.build_mode_combo = QComboBox() self.build_mode_combo.addItems(["release", "debug"]) form.addRow("Build Mode:", self.build_mode_combo) output_row = QHBoxLayout() default_output = os.path.join(self.project_path, "build") self.output_edit = QLineEdit(default_output) self.output_browse = QPushButton("Browse...") self.output_browse.clicked.connect(self._choose_output_folder) output_row.addWidget(self.output_edit) output_row.addWidget(self.output_browse) form.addRow("Output Folder:", output_row) # Mobile-specific options self.mobile_format_row = (QLabel("Android Format:"), QComboBox()) self.mobile_format_combo = self.mobile_format_row[1] self.mobile_format_combo.addItems(["APK", "AAB"]) form.addRow(*self.mobile_format_row) self.mobile_buildozer_row = (QLabel(""), QCheckBox("Run Buildozer build after export")) self.mobile_buildozer_check = self.mobile_buildozer_row[1] self.mobile_buildozer_check.setChecked(False) form.addRow(*self.mobile_buildozer_row) # Desktop-specific options self.desktop_target_os_row = (QLabel("Desktop Target OS:"), QComboBox()) self.desktop_target_os_combo = self.desktop_target_os_row[1] self.desktop_target_os_combo.addItems(["Windows", "Linux", "macOS"]) current_os = platform.system().lower() if current_os == "darwin": self.desktop_target_os_combo.setCurrentText("macOS") elif current_os != "windows": self.desktop_target_os_combo.setCurrentText("Linux") self.desktop_target_os_combo.currentTextChanged.connect(self._on_desktop_os_changed) form.addRow(*self.desktop_target_os_row) self.desktop_pyinstaller_row = (QLabel(""), QCheckBox("Build executable with PyInstaller")) self.desktop_pyinstaller_check = self.desktop_pyinstaller_row[1] self.desktop_pyinstaller_check.setChecked(True) self.desktop_pyinstaller_check.toggled.connect(lambda _: self._sync_target_os_controls()) form.addRow(*self.desktop_pyinstaller_row) # Server-specific options self.server_target_os_row = (QLabel("Server Target OS:"), QComboBox()) self.server_target_os_combo = self.server_target_os_row[1] self.server_target_os_combo.addItems(["Windows", "Linux"]) if current_os != "windows": self.server_target_os_combo.setCurrentText("Linux") self.server_target_os_combo.currentTextChanged.connect(lambda _: self._on_target_changed(self.target_combo.currentText())) form.addRow(*self.server_target_os_row) self.server_pyinstaller_row = (QLabel(""), QCheckBox("Build standalone server executable with PyInstaller")) self.server_pyinstaller_check = self.server_pyinstaller_row[1] self.server_pyinstaller_check.setChecked(False) self.server_pyinstaller_check.toggled.connect(lambda _: self._sync_target_os_controls()) form.addRow(*self.server_pyinstaller_row) self.status_label = QLabel("") self.status_label.setWordWrap(True) form.addRow("Status:", self.status_label) self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 0) self.progress_bar.setTextVisible(True) self.progress_bar.setFormat("Exporting...") self.progress_bar.setVisible(False) form.addRow("", self.progress_bar) main_layout.addLayout(form) buttons = QHBoxLayout() buttons.addStretch() self.export_button = QPushButton("Export") self.export_button.clicked.connect(self._run_export) self.open_folder_button = QPushButton("Open Build Folder") self.open_folder_button.clicked.connect(self._open_build_folder) self.open_folder_button.setVisible(False) self.stop_server_button = QPushButton("Stop Server") self.stop_server_button.clicked.connect(self._stop_server) self.stop_server_button.setVisible(False) self.close_button = QPushButton("Close") self.close_button.clicked.connect(self._on_close) self.stop_export_button = QPushButton("Stop Export") self.stop_export_button.clicked.connect(self._stop_export) self.stop_export_button.setVisible(False) buttons.addWidget(self.export_button) buttons.addWidget(self.stop_export_button) buttons.addWidget(self.open_folder_button) buttons.addWidget(self.stop_server_button) buttons.addWidget(self.close_button) main_layout.addLayout(buttons) self._export_thread = None self._export_result = None self._export_error = None self._export_done = False self._export_cancelled = False self._last_output_path = None self._export_target_name = "" self._export_build_mode = "" self._export_output_path = "" self._current_exporter = None self._poll_timer = QTimer(self) self._poll_timer.timeout.connect(self._check_export_done) self._sync_target_os_controls() self._on_target_changed(self.target_combo.currentText()) def _host_desktop_os_label(self): current_os = platform.system().lower() if current_os == "darwin": return "macOS" if current_os == "windows": return "Windows" return "Linux" def _sync_target_os_controls(self): host_label = self._host_desktop_os_label() if self.desktop_pyinstaller_check.isChecked(): self.desktop_target_os_combo.setCurrentText(host_label) self.desktop_target_os_combo.setEnabled(False) else: self.desktop_target_os_combo.setEnabled(True) if self.server_pyinstaller_check.isChecked(): host_server = "Windows" if host_label == "Windows" else "Linux" self.server_target_os_combo.setCurrentText(host_server) self.server_target_os_combo.setEnabled(False) else: self.server_target_os_combo.setEnabled(True) def _on_target_changed(self, target_name: str): # Show/hide Mobile options self.mobile_format_row[0].setVisible(target_name == "Mobile") self.mobile_format_combo.setVisible(target_name == "Mobile") self.mobile_buildozer_row[0].setVisible(target_name == "Mobile") self.mobile_buildozer_check.setVisible(target_name == "Mobile") # Show/hide Desktop options self.desktop_target_os_row[0].setVisible(target_name == "Desktop") self.desktop_target_os_combo.setVisible(target_name == "Desktop") self.desktop_pyinstaller_row[0].setVisible(target_name == "Desktop") self.desktop_pyinstaller_check.setVisible(target_name == "Desktop") # Show/hide Server options self.server_target_os_row[0].setVisible(target_name == "Server") self.server_target_os_combo.setVisible(target_name == "Server") self.server_pyinstaller_row[0].setVisible(target_name == "Server") self.server_pyinstaller_check.setVisible(target_name == "Server") platform_name = target_name.lower() output_root = os.path.normpath(self.output_edit.text().strip() or os.path.join(self.project_path, "build")) if target_name == "Mobile": self.status_label.setText(f"Output path will be: {os.path.join(output_root, platform_name, 'android')}") elif target_name == "Desktop": os_name = self.desktop_target_os_combo.currentText().lower() msg = f"Output path will be: {os.path.join(output_root, platform_name, os_name)}" if self.desktop_pyinstaller_check.isChecked(): msg += " | PyInstaller executable target is host OS only" self.status_label.setText(msg) elif target_name == "Server": os_name = self.server_target_os_combo.currentText().lower() msg = f"Output path will be: {os.path.join(output_root, platform_name, os_name)}" if self.server_pyinstaller_check.isChecked(): msg += " | PyInstaller executable target is host OS only" self.status_label.setText(msg) else: self.status_label.setText(f"Output path will be: {os.path.join(output_root, platform_name)}") def _on_desktop_os_changed(self, os_name: str): labels = {"Windows": "Build .exe with PyInstaller", "Linux": "Build Linux binary with PyInstaller", "macOS": "Build .app bundle with PyInstaller"} self.desktop_pyinstaller_check.setText(labels.get(os_name, "Build executable with PyInstaller")) self._on_target_changed(self.target_combo.currentText()) def _choose_output_folder(self): start_dir = self.output_edit.text().strip() or self.project_path chosen = QFileDialog.getExistingDirectory(self, "Choose Export Output Folder", start_dir) if chosen: self.output_edit.setText(os.path.normpath(chosen)) self._on_target_changed(self.target_combo.currentText()) def _run_export(self): target_name = self.target_combo.currentText() self._sync_target_os_controls() output_path = os.path.normpath(self.output_edit.text().strip()) if not output_path: QMessageBox.warning(self, "Missing Output Folder", "Please choose an output folder.") return os.makedirs(output_path, exist_ok=True) build_mode = self.build_mode_combo.currentText() exporter_type = self._exporter_by_name[target_name] if target_name == "Mobile": output_format = self.mobile_format_combo.currentText().lower() exporter = exporter_type(build_mode=build_mode, output_format=output_format) elif target_name == "Desktop": desktop_target_os = self.desktop_target_os_combo.currentText().lower() exporter = exporter_type(build_mode=build_mode, target_os=desktop_target_os) elif target_name == "Server": server_target_os = self.server_target_os_combo.currentText().lower() exporter = exporter_type(build_mode=build_mode, target_os=server_target_os) else: exporter = exporter_type(build_mode=build_mode) self.export_button.setEnabled(False) self.stop_export_button.setVisible(True) self.open_folder_button.setVisible(False) self.status_label.setText("Exporting...") self.progress_bar.setVisible(True) self.progress_bar.setRange(0, 0) self.progress_bar.setFormat("Exporting...") self._export_result = None self._export_error = None self._export_done = False self._export_cancelled = False self._current_exporter = exporter self._export_target_name = target_name self._export_build_mode = build_mode self._export_output_path = output_path def do_export(): try: if target_name == "Mobile" and self.mobile_buildozer_check.isChecked() and hasattr(exporter, "export_with_buildozer"): context = exporter.export_with_buildozer(self.project_path, output_path) elif target_name == "Web" and hasattr(exporter, "export_with_pygbag"): context = exporter.export_with_pygbag(self.project_path, output_path) elif target_name == "Desktop" and self.desktop_pyinstaller_check.isChecked() and hasattr(exporter, "export_with_pyinstaller"): context = exporter.export_with_pyinstaller(self.project_path, output_path) elif target_name == "Server" and self.server_pyinstaller_check.isChecked() and hasattr(exporter, "export_with_pyinstaller"): context = exporter.export_with_pyinstaller(self.project_path, output_path) else: context = exporter.export(self.project_path, output_path) self._export_result = context except ExportCancelled: self._export_cancelled = True except Exception as error: self._export_error = error finally: self._export_done = True self._export_thread = threading.Thread(target=do_export, daemon=True) self._export_thread.start() self._poll_timer.start(250) def _stop_export(self): if self._current_exporter is not None: self._current_exporter.cancelled.set() self.stop_export_button.setEnabled(False) self.status_label.setText("Cancelling...") def _check_export_done(self): if not self._export_done: return self._poll_timer.stop() self.progress_bar.setRange(0, 100) self.export_button.setEnabled(True) self.stop_export_button.setVisible(False) self.stop_export_button.setEnabled(True) self._current_exporter = None if self._export_cancelled: self.progress_bar.setValue(0) self.progress_bar.setFormat("Export cancelled") self.status_label.setText("Export cancelled by user.") self._logger.info("Export cancelled by user", target=self._export_target_name) return if self._export_error: self.progress_bar.setValue(0) self.progress_bar.setFormat("Export failed") self.status_label.setText(f"Failed: {self._export_error}") self._logger.error("Project export failed", target=self._export_target_name, output_path=self._export_output_path, error=str(self._export_error)) QMessageBox.critical(self, "Export Failed", str(self._export_error)) return context = self._export_result self.progress_bar.setValue(100) self.progress_bar.setFormat("Export complete!") self._last_output_path = context.output_path self.status_label.setText(f"Done. Output: {context.output_path}") self.open_folder_button.setVisible(True) self._logger.info( "Project export completed", target=self._export_target_name, build_mode=self._export_build_mode, output_path=context.output_path ) if self._export_target_name == "Web": self._start_web_preview(context) def _open_build_folder(self): folder = self._last_output_path or self.output_edit.text().strip() if not folder or not os.path.isdir(folder): folder = self.output_edit.text().strip() if folder and os.path.isdir(folder): system = platform.system() if system == "Windows": os.startfile(folder) elif system == "Darwin": subprocess.Popen(["open", folder]) else: subprocess.Popen(["xdg-open", folder]) def _start_web_preview(self, context): web_dist = context.metadata.get("web_dist_path", context.output_path) if not os.path.isdir(web_dist): web_dist = context.output_path self._stop_server() port = 8000 self._server_process = subprocess.Popen( [sys.executable, "-m", "http.server", str(port)], cwd=web_dist, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) url = f"http://127.0.0.1:{port}" self.status_label.setText(f"Server running at {url} (PID {self._server_process.pid})") self.stop_server_button.setVisible(True) self._logger.info("Web preview server started", url=url, pid=self._server_process.pid, cwd=web_dist) self._open_browser_incognito(url) @staticmethod def _open_browser_incognito(url: str): system = platform.system() try: if system == "Windows": # Try Chrome first, then Edge, then fallback for browser_path, flag in [ (r"C:\Program Files\Google\Chrome\Application\chrome.exe", "--incognito"), (r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", "--incognito"), (r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", "--inprivate"), (r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", "--inprivate"), ]: if os.path.exists(browser_path): subprocess.Popen([browser_path, flag, url]) return webbrowser.open(url) elif system == "Darwin": subprocess.Popen(["open", "-na", "Google Chrome", "--args", "--incognito", url]) else: subprocess.Popen(["google-chrome", "--incognito", url]) except Exception: webbrowser.open(url) def _stop_server(self): if self._server_process and self._server_process.poll() is None: self._server_process.terminate() try: self._server_process.wait(timeout=3) except subprocess.TimeoutExpired: self._server_process.kill() self._logger.info("Web preview server stopped", pid=self._server_process.pid) self._server_process = None self.stop_server_button.setVisible(False) self.status_label.setText("Server stopped.") def _on_close(self): self._stop_server() self.reject()
[docs] def closeEvent(self, event): self._stop_server() super().closeEvent(event)