#!/usr/bin/env python3 """ Affinity Linux Installer - PyQt6 GUI Version A modern, professional GUI application for installing Affinity software on Linux """ import os import sys import subprocess import shutil import tarfile import zipfile import threading import platform import urllib.request import urllib.error import re from pathlib import Path import time import signal import shlex # Function to detect Linux distribution def detect_distro_for_install(): """Detect distribution for package installation""" try: with open("/etc/os-release", "r") as f: content = f.read() for line in content.split("\n"): if line.startswith("ID="): distro = line.split("=", 1)[1].strip().strip('"').lower() # Normalize "pika" to "pikaos" if detected if distro == "pika": distro = "pikaos" return distro except (IOError, FileNotFoundError): pass return None # Function to install Python package def install_package(package_name, import_name=None): """Install a Python package if not available""" if import_name is None: import_name = package_name try: __import__(import_name) return True except ImportError: print(f"Installing {package_name}...") # Try with --break-system-packages for PEP 668 systems distro = detect_distro_for_install() pip_flags = ["--user"] if distro in ["arch", "cachyos", "manjaro", "endeavouros", "xerolinux"]: pip_flags.append("--break-system-packages") # Add quiet only if we're not showing output if not sys.stdout.isatty(): pip_flags.insert(0, "--quiet") try: subprocess.check_call( [sys.executable, "-m", "pip", "install", package_name] + pip_flags, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL ) try: __import__(import_name) print(f"✓ {package_name} installed successfully") return True except ImportError: print(f"✗ Failed to import {package_name} after installation") return False except subprocess.CalledProcessError: print(f"✗ Failed to install {package_name} via pip") return False except Exception as e: print(f"✗ Error installing {package_name}: {e}") return False # Check and install PyQt6 PYQT6_AVAILABLE = False try: from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QMessageBox, QTextEdit, QFrame, QProgressBar, QGroupBox, QScrollArea, QDialog, QDialogButtonBox, QButtonGroup, QRadioButton, QInputDialog, QSlider, QLineEdit ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer from PyQt6.QtGui import QFont, QColor, QPalette, QIcon, QPixmap, QShortcut, QKeySequence, QWheelEvent, QPainter, QPen from PyQt6.QtSvgWidgets import QSvgWidget PYQT6_AVAILABLE = True except ImportError: print("PyQt6 not found. Attempting to install...") if install_package("PyQt6", "PyQt6"): try: from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFileDialog, QMessageBox, QTextEdit, QFrame, QProgressBar, QGroupBox, QScrollArea, QDialog, QDialogButtonBox, QButtonGroup, QRadioButton, QInputDialog, QSlider, QLineEdit ) from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer from PyQt6.QtGui import QFont, QColor, QPalette, QIcon, QPixmap, QShortcut, QKeySequence, QWheelEvent from PyQt6.QtSvgWidgets import QSvgWidget PYQT6_AVAILABLE = True print("✓ PyQt6 installed and imported successfully") except ImportError as e: print(f"✗ Failed to import PyQt6 after installation: {e}") PYQT6_AVAILABLE = False else: print("✗ Failed to install PyQt6 via pip") PYQT6_AVAILABLE = False if not PYQT6_AVAILABLE: print("\nERROR: PyQt6 is required but could not be installed.") print("Please install PyQt6 manually using one of these methods:\n") print("Using pip:") print(" pip install --user PyQt6") print("\nOr using your distribution's package manager:") print(" Arch/CachyOS/EndeavourOS/XeroLinux: sudo pacman -S python-pyqt6") print(" Fedora/Nobara: sudo dnf install python3-pyqt6") print(" Debian/Ubuntu/Mint/Pop/Zorin/PikaOS: sudo apt install python3-pyqt6") print(" openSUSE: sudo zypper install python313-PyQt6") sys.exit(1) class ZoomableTextEdit(QTextEdit): """QTextEdit with Ctrl+Wheel zoom support""" def __init__(self, parent=None): super().__init__(parent) self.zoom_in_callback = None self.zoom_out_callback = None def set_zoom_callbacks(self, zoom_in, zoom_out): """Set callbacks for zoom in/out""" self.zoom_in_callback = zoom_in self.zoom_out_callback = zoom_out def wheelEvent(self, event): """Handle mouse wheel events for zoom (Ctrl+Wheel) or scroll""" if event.modifiers() & Qt.KeyboardModifier.ControlModifier: # Zoom with Ctrl+Wheel delta = event.angleDelta().y() if delta > 0: self.zoomIn(1) if self.zoom_in_callback: self.zoom_in_callback() elif delta < 0: self.zoomOut(1) if self.zoom_out_callback: self.zoom_out_callback() else: # Normal scrolling super().wheelEvent(event) class ProgressSpinner(QWidget): """A simple rotating spinner widget (indeterminate progress).""" def __init__(self, size=22, line_width=3, color=QColor('#8ff361'), parent=None): super().__init__(parent) self._angle = 0 self._timer = QTimer(self) self._timer.setInterval(50) self._timer.timeout.connect(self._on_timeout) self._size = size self._line_width = line_width self._color = color self.setFixedSize(self._size, self._size) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) def start(self): self._timer.start() self.update() def stop(self): self._timer.stop() self.update() def _on_timeout(self): self._angle = (self._angle - 30) % 360 self.update() def paintEvent(self, event): # noqa: N802 - Qt override painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) rect = self.rect().adjusted(self._line_width, self._line_width, -self._line_width, -self._line_width) pen = QPen(self._color) pen.setWidth(self._line_width) painter.setPen(pen) # Draw an arc (270 degrees) rotating around start_angle = int(self._angle * 16) span_angle = int(270 * 16) painter.drawArc(rect, start_angle, span_angle) painter.end() class AffinityInstallerGUI(QMainWindow): # Signals for thread-safe GUI updates log_signal = pyqtSignal(str, str) # message, level progress_signal = pyqtSignal(float) # value (0.0-1.0) progress_text_signal = pyqtSignal(str) # progress text show_message_signal = pyqtSignal(str, str, str) # title, message, type (info/error/warning) sudo_password_dialog_signal = pyqtSignal() # Signal to request sudo password interactive_prompt_signal = pyqtSignal(str, str) # prompt_text, default_response question_dialog_signal = pyqtSignal(str, str, list) # title, message, buttons prompt_affinity_install_signal = pyqtSignal() # Signal to prompt for Affinity installation install_application_signal = pyqtSignal(str) # Signal to install an application show_spinner_signal = pyqtSignal(object) # button -> show spinner hide_spinner_signal = pyqtSignal(object) # button -> hide spinner def __init__(self): super().__init__() self.setWindowTitle("Affinity Linux Installer") # Use a more reasonable initial size that fits smaller screens self.setMinimumSize(800, 600) self.resize(1000, 700) # Variables self.distro = None self.distro_version = None self.directory = str(Path.home() / ".AffinityLinux") self.setup_complete = False self.installer_file = None self.update_buttons = {} # Store references to update buttons self.log_font_size = 11 # Initial font size for log area self.operation_cancelled = False # Flag to track if operation was cancelled self.current_operation = None # Track current operation name self.operation_in_progress = False # Track if an operation is running self.sudo_password = None # Cached sudo password self.sudo_password_validated = False # Whether password has been validated self.interactive_response = None # Response to interactive prompts self.waiting_for_response = False # Whether we're waiting for user response self.question_dialog_response = None # Response from question dialogs self.waiting_for_question_response = False # Whether waiting for question dialog response self.dark_mode = True # Track current theme mode self.icon_buttons = [] # Store buttons with icons for theme updates self.enable_opencl = False # OpenCL support preference # Cancellation helpers self.cancel_event = threading.Event() self._process_lock = threading.Lock() self._active_processes = set() # Spinner helpers self._button_spinner_map = {} self._last_clicked_button = None self._operation_button = None # Setup log file self.log_file_path = Path.home() / "AffinitySetup.log" self.log_file = None self._init_log_file() # Connect signals self.log_signal.connect(self._log_safe) self.progress_signal.connect(self._update_progress_safe) self.progress_text_signal.connect(self._update_progress_text_safe) self.show_message_signal.connect(self._show_message_safe) self.sudo_password_dialog_signal.connect(self._request_sudo_password_safe) self.interactive_prompt_signal.connect(self._request_interactive_response_safe) self.question_dialog_signal.connect(self._show_question_dialog_safe) self.prompt_affinity_install_signal.connect(self._prompt_affinity_install) self.install_application_signal.connect(self.install_application) self.show_spinner_signal.connect(self._show_spinner_safe) self.hide_spinner_signal.connect(self._hide_spinner_safe) # Load Affinity icon self.load_affinity_icon() # Setup UI self.create_ui() # Ensure icons directory exists (download from GitHub if needed) # Do this after UI is created so we can log messages self._ensure_icons_directory() # Apply dark theme (default) self.apply_theme() # Setup zoom functionality self.setup_zoom() # Center window self.center_window() # Don't auto-start initialization - user will click button self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Affinity Linux Installer - Ready", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Ensure patcher files are available (silently) self.ensure_patcher_files(silent=True) self.log("System Detection:", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info") # Check installation status and update button states self.check_installation_status() self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info") self.log("Welcome! Please use the buttons on the right to get started.", "info") wine_path = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_path.exists(): self.log("Click 'Setup Wine Environment' or 'One-Click Full Setup' to begin.", "info") else: self.log("Wine is set up. Use 'Update Affinity Applications' to install or update apps.", "info") def check_installation_status(self): """Check if Wine and Affinity applications are installed, and update button states""" # Check if Wine is set up wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" wine_exists = wine.exists() # Update system status indicator if hasattr(self, 'system_status_label'): if wine_exists: self.system_status_label.setStyleSheet("font-size: 14px; color: #4ec9b0; padding: 0 5px;") self.system_status_label.setToolTip("System Status: Ready - Wine is installed") else: self.system_status_label.setStyleSheet("font-size: 14px; color: #f48771; padding: 0 5px;") self.system_status_label.setToolTip("System Status: Not Ready - Wine needs to be installed") # Log Wine status if wine_exists: self.log("Wine: ✓ Installed (ElementalWarriorWine)", "success") else: self.log("Wine: ✗ Not installed", "error") # Check each Affinity application app_status = {} app_names_display = { "Add": "Affinity (Unified)", "Photo": "Affinity Photo", "Designer": "Affinity Designer", "Publisher": "Affinity Publisher" } app_dirs = { "Add": ("Affinity", "Affinity.exe"), "Photo": ("Photo 2", "Photo.exe"), "Designer": ("Designer 2", "Designer.exe"), "Publisher": ("Publisher 2", "Publisher.exe") } self.log("Affinity Applications:", "info") for app_name, (dir_name, exe_name) in app_dirs.items(): app_path = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / dir_name / exe_name is_installed = app_path.exists() app_status[app_name] = is_installed display_name = app_names_display.get(app_name, app_name) if is_installed: self.log(f" {display_name}: ✓ Installed", "success") else: self.log(f" {display_name}: ✗ Not installed", "error") # Update button text to show installation status if app_name in self.update_buttons: btn = self.update_buttons[app_name] if is_installed: # Add checkmark to button text if installed current_text = btn.text() if "✓" not in current_text: btn.setText(current_text.split("✓")[0].strip() + " ✓") btn.setEnabled(True) # Check dependencies self.log("System Dependencies:", "info") deps = ["wine", "winetricks", "wget", "curl", "7z", "tar", "jq"] deps_installed = True for dep in deps: if self.check_command(dep): self.log(f" {dep}: ✓ Installed", "success") else: self.log(f" {dep}: ✗ Not installed", "error") deps_installed = False # Check zstd if self.check_command("unzstd") or self.check_command("zstd"): self.log(f" zstd: ✓ Installed", "success") else: self.log(f" zstd: ✗ Not installed", "error") deps_installed = False # Check .NET SDK if self.check_dotnet_sdk(): self.log(f" .NET SDK: ✓ Installed", "success") else: self.log(f" .NET SDK: ✗ Not installed", "error") # Don't set deps_installed = False since .NET SDK is optional (only needed for settings fix) # Check Winetricks Dependencies (only if Wine is set up) if wine_exists: self.log("Winetricks Dependencies:", "info") env = os.environ.copy() env["WINEPREFIX"] = self.directory wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" # List of winetricks components to check winetricks_components = [ ("dotnet35", ".NET Framework 3.5"), ("dotnet48", ".NET Framework 4.8"), ("corefonts", "Windows Core Fonts"), ("vcrun2022", "Visual C++ Redistributables 2022"), ("msxml3", "MSXML 3.0"), ("msxml6", "MSXML 6.0"), ] for component, description in winetricks_components: if self._check_winetricks_component(component, wine, env): self.log(f" {description}: ✓ Installed", "success") else: self.log(f" {description}: ✗ Not installed", "error") # Check Vulkan renderer try: success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D"], check=False, env=env, capture=True ) if success: # Check if renderer is set to vulkan vulkan_set = False try: renderer_success, renderer_stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer"], check=False, env=env, capture=True ) if renderer_success and "vulkan" in renderer_stdout.lower(): vulkan_set = True except Exception: pass if vulkan_set: self.log(f" Vulkan Renderer: ✓ Configured", "success") else: self.log(f" Vulkan Renderer: ⚠ Not configured", "warning") else: self.log(f" Vulkan Renderer: ✗ Not configured", "error") except Exception: self.log(f" Vulkan Renderer: ✗ Not configured", "error") # Check WebView2 Runtime self.log("WebView2 Runtime:", "info") if self.check_webview2_installed(): self.log(f" Microsoft Edge WebView2 Runtime: ✓ Installed", "success") else: self.log(f" Microsoft Edge WebView2 Runtime: ✗ Not installed", "error") self.log("", "info") # Empty line for spacing # Update button states for app_name, button in self.update_buttons.items(): if button is None: continue # Button should be enabled only if Wine is set up AND the app is installed is_installed = app_status.get(app_name, False) enabled = wine_exists and is_installed button.setEnabled(enabled) if enabled: # Reset to default style button.setStyleSheet("") def center_window(self): """Center window on screen""" frame = self.frameGeometry() screen = self.screen().availableGeometry().center() frame.moveCenter(screen) self.move(frame.topLeft()) def setup_zoom(self): """Setup zoom in/out functionality for log area""" # Zoom in: Ctrl+Plus or Ctrl+= zoom_in_shortcut = QShortcut(QKeySequence("Ctrl+Plus"), self) zoom_in_shortcut.activated.connect(self.zoom_in) zoom_in_shortcut_alt = QShortcut(QKeySequence("Ctrl+="), self) zoom_in_shortcut_alt.activated.connect(self.zoom_in) # Zoom out: Ctrl+Minus or Ctrl+- zoom_out_shortcut = QShortcut(QKeySequence("Ctrl+Minus"), self) zoom_out_shortcut.activated.connect(self.zoom_out) zoom_out_shortcut_alt = QShortcut(QKeySequence("Ctrl+-"), self) zoom_out_shortcut_alt.activated.connect(self.zoom_out) # Reset zoom: Ctrl+0 zoom_reset_shortcut = QShortcut(QKeySequence("Ctrl+0"), self) zoom_reset_shortcut.activated.connect(self.zoom_reset) def zoom_in(self): """Zoom in (increase font size)""" if not hasattr(self, 'log_text') or not self.log_text: return new_size = min(self.log_font_size + 1, 48) if new_size != self.log_font_size: self.log_font_size = new_size font = QFont("Consolas", self.log_font_size) self.log_text.setFont(font) # Also set document default font to affect HTML content self.log_text.document().setDefaultFont(font) self.update_zoom_buttons() def zoom_out(self): """Zoom out (decrease font size)""" if not hasattr(self, 'log_text') or not self.log_text: return new_size = max(self.log_font_size - 1, 6) if new_size != self.log_font_size: self.log_font_size = new_size font = QFont("Consolas", self.log_font_size) self.log_text.setFont(font) # Also set document default font to affect HTML content self.log_text.document().setDefaultFont(font) self.update_zoom_buttons() def zoom_reset(self): """Reset zoom to default size""" if not hasattr(self, 'log_text') or not self.log_text: return self.log_font_size = 11 font = QFont("Consolas", 11) self.log_text.setFont(font) # Also set document default font to affect HTML content self.log_text.document().setDefaultFont(font) self.update_zoom_buttons() def update_zoom_buttons(self): """Update zoom button states""" try: if hasattr(self, 'log_text') and self.log_text: current_font = self.log_text.currentFont() current_size = current_font.pointSize() if current_font else self.log_font_size if hasattr(self, 'zoom_in_btn'): self.zoom_in_btn.setEnabled(current_size < 48) if hasattr(self, 'zoom_out_btn'): self.zoom_out_btn.setEnabled(current_size > 6) except Exception: pass def get_icon_path(self, icon_name): """Get the path to a light or dark icon based on theme""" if not icon_name: return None # Use standard location in user's config directory # This works even when script is piped from curl icons_dir = Path.home() / ".config" / "AffinityOnLinux" / "AffinityScripts" / "icons" theme_suffix = "light" if self.dark_mode else "dark" # Check for theme-specific icon first themed_icon_path = icons_dir / f"{icon_name}-{theme_suffix}.svg" if themed_icon_path.exists(): return themed_icon_path # Fallback to base icon name if theme-specific one doesn't exist base_icon_path = icons_dir / f"{icon_name}.svg" if base_icon_path.exists(): return base_icon_path return None def _update_button_icons(self): """Update all button icons to match the current theme""" for btn, icon_name in self.icon_buttons: icon_path = self.get_icon_path(icon_name) if icon_path: icon = QIcon(str(icon_path)) btn.setIcon(icon) def toggle_theme(self): """Toggle between dark and light themes""" self.dark_mode = not self.dark_mode self.apply_theme() # Update theme button if self.dark_mode: self.theme_toggle_btn.setText("☀") self.theme_toggle_btn.setToolTip("Switch to Light Mode") else: self.theme_toggle_btn.setText("🌙") self.theme_toggle_btn.setToolTip("Switch to Dark Mode") # Update button icons self._update_button_icons() # Update top bar self._update_top_bar_style() # Update theme button style self._update_theme_button_style() # Update scroll area self._update_right_scroll_style() # Update progress label self._update_progress_label_style() def apply_theme(self): """Apply current theme (dark or light)""" if self.dark_mode: self._apply_dark_theme() else: self._apply_light_theme() def _apply_dark_theme(self): """Apply modern dark theme""" self.setStyleSheet(""" QMainWindow { background-color: #1c1c1c; } QWidget { background-color: #1c1c1c; color: #dcdcdc; font-family: 'Segoe UI', sans-serif; } QGroupBox { border: 1px solid #2d2d2d; background-color: #252526; margin-top: 8px; padding-top: 12px; border-radius: 8px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 2px 8px; background-color: #3c3c3c; color: #dcdcdc; font-weight: bold; font-size: 10px; border-radius: 4px; margin-left: 10px; } QFrame { background-color: #252526; border: none; border-radius: 0px; } QPushButton { background-color: #3c3c3c; color: #f0f0f0; border: 1px solid #555555; padding: 8px 12px; min-height: 28px; font-size: 11px; font-weight: 500; text-align: left; border-radius: 6px; } QPushButton:hover { background-color: #4a4a4a; border-color: #6a6a6a; color: #ffffff; } QPushButton:disabled { background-color: #2a2a2a; color: #555555; border-color: #3a3a3a; } QPushButton:pressed { background-color: #2d2d2d; border-color: #4a4a4a; } QPushButton[class="primary"] { background-color: #6b8e6b; color: #000000; font-weight: bold; font-size: 12px; border: 1px solid #5a7a5a; } QPushButton[class="primary"]:hover { background-color: #7a9e7a; border-color: #6b8e6b; color: #000000; } QTextEdit { background-color: #1a1a1a; color: #d4d4d4; border: 1px solid #333333; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 11px; border-radius: 8px; selection-background-color: #007acc; padding: 8px; } QProgressBar { border: none; background-color: #2d2d30; height: 10px; border-radius: 5px; text-align: center; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #8ff361, stop:1 #9af471); border-radius: 5px; } QLabel { color: #dcdcdc; } QToolTip { background-color: #2d2d30; color: #dcdcdc; border: 1px solid #4a4a4a; padding: 6px; border-radius: 4px; font-size: 10px; } QDialog { background-color: #252526; border-radius: 12px; } QMessageBox { background-color: #252526; border-radius: 12px; } QRadioButton { color: #dcdcdc; spacing: 8px; } QRadioButton::indicator { width: 16px; height: 16px; border-radius: 8px; border: 2px solid #555555; background-color: #3c3c3c; } QRadioButton::indicator:hover { border-color: #6a6a6a; } QRadioButton::indicator:checked { background-color: #007acc; border-color: #007acc; } QDialogButtonBox QPushButton { border-radius: 8px; min-width: 80px; padding: 8px 16px; } QPushButton[zoomButton="true"] { background-color: #2d2d2d; color: #dcdcdc; border: 1px solid #4a4a4a; padding: 4px 8px; min-height: 24px; max-width: 35px; font-size: 14px; border-radius: 6px; } QPushButton[zoomButton="true"]:hover { background-color: #3c3c3c; border-color: #5a5a5a; } QPushButton[zoomButton="true"]:disabled { background-color: #252526; color: #555555; border-color: #2d2d2d; } QPushButton[cancelButton="true"] { background-color: #c74e4e; color: #ffffff; border: 1px solid #a33a3a; padding: 4px 8px; min-height: 24px; max-width: 30px; font-size: 16px; font-weight: bold; border-radius: 6px; } QPushButton[cancelButton="true"]:hover { background-color: #d95f5f; border-color: #b74a4a; } QPushButton[cancelButton="true"]:pressed { background-color: #a33a3a; } """) def _apply_light_theme(self): """Apply modern light theme""" self.setStyleSheet(""" QMainWindow { background-color: #f5f5f5; } QWidget { background-color: #f5f5f5; color: #2d2d2d; font-family: 'Segoe UI', sans-serif; } QGroupBox { border: 1px solid #d0d0d0; background-color: #ffffff; margin-top: 8px; padding-top: 12px; border-radius: 8px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 2px 8px; background-color: #e0e0e0; color: #2d2d2d; font-weight: bold; font-size: 10px; border-radius: 4px; margin-left: 10px; } QFrame { background-color: #ffffff; border: none; border-radius: 0px; } QPushButton { background-color: #e0e0e0; color: #2d2d2d; border: 1px solid #c0c0c0; padding: 8px 12px; min-height: 28px; font-size: 11px; font-weight: 500; text-align: left; border-radius: 6px; } QPushButton:hover { background-color: #d0d0d0; border-color: #a0a0a0; color: #1a1a1a; } QPushButton:disabled, QPushButton[class="primary"]:disabled { background-color: #e0e0e0; color: #a0a0a0; border: 1px solid #c5c5c5; } QPushButton[class="primary"] { background-color: #9bc49b; color: #1c1c1c; font-weight: bold; font-size: 12px; border: 1px solid #8ab48a; } QPushButton[class="primary"]:hover { background-color: #a8d0a8; border-color: #9bc49b; } QTextEdit { background-color: #fafafa; color: #1a1a1a; border: 1px solid #d0d0d0; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; font-size: 11px; border-radius: 8px; selection-background-color: #b3d9ff; padding: 8px; } QProgressBar { border: none; background-color: #e0e0e0; height: 10px; border-radius: 5px; text-align: center; } QProgressBar::chunk { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #4caf50, stop:1 #66bb6a); border-radius: 5px; } QLabel { color: #2d2d2d; } QToolTip { background-color: #ffffff; color: #2d2d2d; border: 1px solid #c0c0c0; padding: 6px; border-radius: 4px; font-size: 10px; } QDialog { background-color: #ffffff; border-radius: 12px; } QMessageBox { background-color: #ffffff; border-radius: 12px; } QRadioButton { color: #2d2d2d; spacing: 8px; } QRadioButton::indicator { width: 16px; height: 16px; border-radius: 8px; border: 2px solid #c0c0c0; background-color: #f5f5f5; } QRadioButton::indicator:hover { border-color: #a0a0a0; } QRadioButton::indicator:checked { background-color: #4caf50; border-color: #4caf50; } QDialogButtonBox QPushButton { border-radius: 8px; min-width: 80px; padding: 8px 16px; } QPushButton[zoomButton="true"] { background-color: #e0e0e0; color: #2d2d2d; border: 1px solid #c0c0c0; padding: 4px 8px; min-height: 24px; max-width: 35px; font-size: 14px; border-radius: 6px; } QPushButton[zoomButton="true"]:hover { background-color: #d0d0d0; border-color: #a0a0a0; } QPushButton[zoomButton="true"]:disabled { background-color: #e0e0e0; color: #a0a0a0; border-color: #c5c5c5; } QPushButton[cancelButton="true"] { background-color: #f44336; color: #ffffff; border: 1px solid #d32f2f; padding: 4px 8px; min-height: 24px; max-width: 30px; font-size: 16px; font-weight: bold; border-radius: 6px; } QPushButton[cancelButton="true"]:hover { background-color: #e57373; border-color: #ef5350; } QPushButton[cancelButton="true"]:pressed { background-color: #d32f2f; } """) def _update_theme_button_style(self): """Update theme toggle button styling based on current theme""" if hasattr(self, 'theme_toggle_btn'): if self.dark_mode: self.theme_toggle_btn.setStyleSheet(""" QPushButton { background-color: #3c3c3c; color: #f0f0f0; border: 1px solid #555555; padding: 0px; min-height: 32px; max-height: 32px; min-width: 40px; max-width: 40px; font-size: 18px; border-radius: 6px; text-align: center; } QPushButton:hover { background-color: #4a4a4a; border-color: #6a6a6a; } """) else: self.theme_toggle_btn.setStyleSheet(""" QPushButton { background-color: #e0e0e0; color: #2d2d2d; border: 1px solid #c0c0c0; padding: 0px; min-height: 32px; max-height: 32px; min-width: 40px; max-width: 40px; font-size: 18px; border-radius: 6px; text-align: center; } QPushButton:hover { background-color: #d0d0d0; border-color: #a0a0a0; } """) def _update_top_bar_style(self): """Update top bar styling based on current theme""" if hasattr(self, 'top_bar'): if self.dark_mode: self.top_bar.setStyleSheet( "background-color: #2d2d2d; padding: 10px 15px; " "border-top-left-radius: 8px; border-top-right-radius: 8px;" ) else: self.top_bar.setStyleSheet( "background-color: #e8e8e8; padding: 10px 15px; " "border-top-left-radius: 8px; border-top-right-radius: 8px;" ) if hasattr(self, 'title_label'): if self.dark_mode: self.title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #ffffff;") else: self.title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #1a1a1a;") def _update_right_scroll_style(self): """Update right scroll area styling based on current theme""" if hasattr(self, 'right_scroll'): if self.dark_mode: self.right_scroll.setStyleSheet(""" QScrollArea { background-color: #1c1c1c; border: none; } QScrollBar:vertical { background-color: #1c1c1c; width: 12px; border-radius: 6px; } QScrollBar::handle:vertical { background-color: #3c3c3c; border-radius: 6px; min-height: 30px; } QScrollBar::handle:vertical:hover { background-color: #4a4a4a; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } """) else: self.right_scroll.setStyleSheet(""" QScrollArea { background-color: #f5f5f5; border: none; } QScrollBar:vertical { background-color: #f5f5f5; width: 12px; border-radius: 6px; } QScrollBar::handle:vertical { background-color: #c0c0c0; border-radius: 6px; min-height: 30px; } QScrollBar::handle:vertical:hover { background-color: #a0a0a0; } QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0px; } QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; } """) def _update_progress_label_style(self): """Update progress label styling based on current theme""" if hasattr(self, 'progress_label'): if self.dark_mode: self.progress_label.setStyleSheet( "font-size: 11px; font-weight: 500; color: #dcdcdc; " "padding: 5px 10px; background-color: #2d2d2d; border-radius: 4px;" ) else: self.progress_label.setStyleSheet( "font-size: 11px; font-weight: 500; color: #2d2d2d; " "padding: 5px 10px; background-color: #e0e0e0; border-radius: 4px;" ) def create_ui(self): """Create the user interface""" # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) # Main layout main_layout = QVBoxLayout(central_widget) main_layout.setSpacing(0) main_layout.setContentsMargins(1, 1, 1, 1) # Top bar self.top_bar = QFrame() self.top_bar.setStyleSheet("background-color: #2d2d2d; padding: 10px 15px; border-top-left-radius: 8px; border-top-right-radius: 8px;") top_bar_layout = QHBoxLayout(self.top_bar) top_bar_layout.setContentsMargins(15, 10, 15, 10) # Add Affinity icon if available if hasattr(self, 'affinity_icon_path') and self.affinity_icon_path: try: # Try to use QSvgWidget for proper SVG rendering try: # Set window icon icon = QIcon(self.affinity_icon_path) self.setWindowIcon(icon) # Use QSvgWidget for proper SVG display svg_widget = QSvgWidget(self.affinity_icon_path) svg_widget.setFixedSize(32, 32) svg_widget.setStyleSheet("background: transparent;") top_bar_layout.addWidget(svg_widget) top_bar_layout.addSpacing(5) except Exception: # Fallback to QIcon if QSvgWidget fails icon = QIcon(self.affinity_icon_path) self.setWindowIcon(icon) icon_label = QLabel() pixmap = icon.pixmap(32, 32) if not pixmap.isNull(): icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) icon_label.setFixedSize(32, 32) top_bar_layout.addWidget(icon_label) top_bar_layout.addSpacing(5) except Exception as e: pass # If icon loading fails, continue without icon self.title_label = QLabel("Affinity on Linux Installer") self.title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #ffffff;") top_bar_layout.addWidget(self.title_label) top_bar_layout.addStretch() # Add system status indicator in top bar self.system_status_label = QLabel("●") self.system_status_label.setStyleSheet( "font-size: 14px; color: #666666; padding: 0 5px;" ) self.system_status_label.setToolTip("System Status: Initializing...") top_bar_layout.addWidget(self.system_status_label) top_bar_layout.addSpacing(12) # Add theme toggle button self.theme_toggle_btn = QPushButton("☀") self.theme_toggle_btn.setToolTip("Switch to Light Mode") self.theme_toggle_btn.setStyleSheet(""" QPushButton { background-color: #3c3c3c; color: #f0f0f0; border: 1px solid #555555; padding: 0px; min-height: 32px; max-height: 32px; min-width: 40px; max-width: 40px; font-size: 18px; border-radius: 6px; text-align: center; } QPushButton:hover { background-color: #4a4a4a; border-color: #6a6a6a; } """) self.theme_toggle_btn.clicked.connect(self.toggle_theme) top_bar_layout.addWidget(self.theme_toggle_btn) main_layout.addWidget(self.top_bar) # Content area with scroll support content_widget = QWidget() content_layout = QHBoxLayout(content_widget) content_layout.setSpacing(10) content_layout.setContentsMargins(10, 10, 10, 10) # Left panel - Status/Log left_panel = self.create_status_section() content_layout.addWidget(left_panel, stretch=3) # Right panel - Buttons (wrapped in scroll area for small screens) right_scroll = QScrollArea() right_scroll.setWidgetResizable(True) right_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) right_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded) right_scroll.setFrameShape(QFrame.Shape.NoFrame) self.right_scroll = right_scroll # Store reference for theme updates self._update_right_scroll_style() right_panel = self.create_button_sections() right_scroll.setWidget(right_panel) right_scroll.setMinimumWidth(320) right_scroll.setMaximumWidth(400) content_layout.addWidget(right_scroll, stretch=1) main_layout.addWidget(content_widget, stretch=1) def create_status_section(self): """Create the status/log output section""" group = QGroupBox("Status & Log Output") group_layout = QVBoxLayout(group) group_layout.setSpacing(8) group_layout.setContentsMargins(12, 22, 12, 12) # Progress status label (above progress bar) self.progress_label = QLabel("Ready") self.progress_label.setStyleSheet( "font-size: 11px; font-weight: 500; color: #dcdcdc; " "padding: 5px 10px; background-color: #2d2d2d; border-radius: 4px;" ) self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) group_layout.addWidget(self.progress_label) # Progress bar and cancel button container progress_container = QWidget() progress_container_layout = QHBoxLayout(progress_container) progress_container_layout.setContentsMargins(0, 0, 0, 0) progress_container_layout.setSpacing(8) self.progress = QProgressBar() self.progress.setRange(0, 100) self.progress.setValue(0) self.progress.setTextVisible(False) progress_container_layout.addWidget(self.progress, stretch=1) # Cancel button (hidden by default) self.cancel_btn = QPushButton("✕") self.cancel_btn.setToolTip("Cancel current operation") self.cancel_btn.setProperty("cancelButton", True) self.cancel_btn.setMaximumWidth(30) self.cancel_btn.setMinimumWidth(30) self.cancel_btn.setVisible(False) self.cancel_btn.clicked.connect(self.cancel_operation) progress_container_layout.addWidget(self.cancel_btn) group_layout.addWidget(progress_container) # Log and zoom controls container log_container = QWidget() log_layout = QVBoxLayout(log_container) log_layout.setContentsMargins(0, 5, 0, 0) log_layout.setSpacing(5) # Zoom controls zoom_container = QWidget() zoom_layout = QHBoxLayout(zoom_container) zoom_layout.setContentsMargins(0, 0, 0, 0) zoom_layout.setSpacing(5) zoom_layout.addStretch() # Get icon path icons_dir = Path(__file__).parent / "icons" # Zoom out button self.zoom_out_btn = QPushButton() self.zoom_out_btn.setToolTip("Zoom Out (Ctrl+-)") self.zoom_out_btn.setProperty("zoomButton", True) self.zoom_out_btn.setMaximumWidth(35) self.zoom_out_btn.setMinimumWidth(35) icon_name_zoom_out = "zoom-out" icon_path_zoom_out = self.get_icon_path(icon_name_zoom_out) if icon_path_zoom_out: self.zoom_out_btn.setIcon(QIcon(str(icon_path_zoom_out))) self.zoom_out_btn.setIconSize(QSize(16, 16)) self.zoom_out_btn.clicked.connect(self.zoom_out) zoom_layout.addWidget(self.zoom_out_btn) self.icon_buttons.append((self.zoom_out_btn, icon_name_zoom_out)) # Zoom reset button self.zoom_reset_btn = QPushButton() self.zoom_reset_btn.setToolTip("Reset Zoom (Ctrl+0)") self.zoom_reset_btn.setProperty("zoomButton", True) self.zoom_reset_btn.setMaximumWidth(35) self.zoom_reset_btn.setMinimumWidth(35) icon_name_zoom_reset = "zoom-original" icon_path_zoom_reset = self.get_icon_path(icon_name_zoom_reset) if icon_path_zoom_reset: self.zoom_reset_btn.setIcon(QIcon(str(icon_path_zoom_reset))) self.zoom_reset_btn.setIconSize(QSize(16, 16)) self.zoom_reset_btn.clicked.connect(self.zoom_reset) zoom_layout.addWidget(self.zoom_reset_btn) self.icon_buttons.append((self.zoom_reset_btn, icon_name_zoom_reset)) # Zoom in button self.zoom_in_btn = QPushButton() self.zoom_in_btn.setToolTip("Zoom In (Ctrl++)") self.zoom_in_btn.setProperty("zoomButton", True) self.zoom_in_btn.setMaximumWidth(35) self.zoom_in_btn.setMinimumWidth(35) icon_name_zoom_in = "zoom-in" icon_path_zoom_in = self.get_icon_path(icon_name_zoom_in) if icon_path_zoom_in: self.zoom_in_btn.setIcon(QIcon(str(icon_path_zoom_in))) self.zoom_in_btn.setIconSize(QSize(16, 16)) self.zoom_in_btn.clicked.connect(self.zoom_in) zoom_layout.addWidget(self.zoom_in_btn) self.icon_buttons.append((self.zoom_in_btn, icon_name_zoom_in)) log_layout.addWidget(zoom_container) # Log output with zoom support self.log_text = ZoomableTextEdit(self) self.log_text.setReadOnly(True) self.log_text.setFont(QFont("Consolas", self.log_font_size)) self.log_text.set_zoom_callbacks(self.zoom_in, self.zoom_out) log_layout.addWidget(self.log_text) group_layout.addWidget(log_container) # Initialize zoom button states self.update_zoom_buttons() return group def create_button_sections(self): """Create organized button sections""" container = QWidget() container_layout = QVBoxLayout(container) container_layout.setSpacing(8) container_layout.setContentsMargins(0, 0, 0, 0) # Get icon path helper icons_dir = Path(__file__).parent / "icons" # Quick Start section - One-click install quick_group = self.create_button_group( "Quick Start", [ ("One-Click Full Setup", self.one_click_setup, "Setup Wine, dependencies, and prepare for Affinity installation", "rocket"), ("Setup Wine Environment", self.setup_wine_environment, "Download and configure Wine environment only", "wine"), ("Install System Dependencies", self.install_system_dependencies, "Install required Linux packages", "dependencies"), ("Install Winetricks Dependencies", self.install_winetricks_deps, "Install Windows components (.NET, fonts, etc.)", "wand"), ] ) container_layout.addWidget(quick_group) # System Setup section sys_group = self.create_button_group( "System Setup", [ ("Download Affinity Installer", self.download_affinity_installer, "Download the latest Affinity installer from official source", "download"), ("Install from File Manager", self.install_from_file, "Install Affinity or any Windows app from a local .exe file", "folderopen"), ] ) container_layout.addWidget(sys_group) # Update Affinity Applications section app_buttons = [ ("Affinity (Unified)", "Add", "Update or install Affinity V3 unified application", "affinity-unified"), ("Affinity Photo", "Photo", "Update or install Affinity Photo for image editing", "camera"), ("Affinity Designer", "Designer", "Update or install Affinity Designer for vector graphics", "pen"), ("Affinity Publisher", "Publisher", "Update or install Affinity Publisher for page layout", "book"), ] app_group = self.create_button_group( "Update Affinity Applications", [(text, lambda name=app_name: self.update_application(name), tooltip, icon) for text, app_name, tooltip, icon in app_buttons], button_refs=self.update_buttons, button_keys=[app_name for _, app_name, _, _ in app_buttons] ) container_layout.addWidget(app_group) # Troubleshooting section troubleshoot_group = self.create_button_group( "Troubleshooting", [ ("Wine Configuration", self.open_winecfg, "Open Wine settings to configure Windows version and libraries", "wine"), ("Winetricks", self.open_winetricks, "Install additional Windows components and dependencies", "wand"), ("Set Windows 11 + Renderer", self.set_windows11_renderer, "Configure Windows version and graphics renderer (Vulkan/OpenGL)", "windows"), ("GPU Selection", self.configure_gpu_selection, "Select which GPU to use for dual GPU setups", "display"), ("Reinstall WinMetadata", self.reinstall_winmetadata, "Fix corrupted Windows metadata files", "loop"), ("WebView2 Runtime (v3)", self.install_webview2_runtime, "Install WebView2 for Affinity V3 Help system", "chrome"), ("Fix Settings (v3)", self.fix_affinity_settings, "Patch Affinity v3 DLL to enable settings saving", "cog"), ("Set DPI Scaling", self.set_dpi_scaling, "Adjust interface size for better readability", "scale"), ("Uninstall", self.uninstall_affinity_linux, "Completely remove Affinity Linux installation", "trash"), ] ) container_layout.addWidget(troubleshoot_group) # Launch section launch_group = self.create_button_group( "Launch", [ ("Launch Affinity v3", self.launch_affinity_v3, "Start Affinity V3 unified application", "play"), ] ) container_layout.addWidget(launch_group) # Other section other_group = self.create_button_group( "Other", [ ("Exit", self.close, "Close the installer", "exit"), ] ) container_layout.addWidget(other_group) container_layout.addStretch() return container def create_button_group(self, title, buttons, button_refs=None, button_keys=None): """Create a grouped button section""" group = QGroupBox(title) group_layout = QVBoxLayout(group) group_layout.setSpacing(4) group_layout.setContentsMargins(10, 20, 10, 10) for idx, button_data in enumerate(buttons): # Handle (text, command), (text, command, tooltip), (text, command, tooltip, icon) formats tooltip = None icon_name = None if len(button_data) == 2: text, command = button_data elif len(button_data) == 3: text, command, tooltip = button_data elif len(button_data) == 4: text, command, tooltip, icon_name = button_data else: text, command = button_data[0], button_data[1] btn = QPushButton(text) # Wrap click to track the button and delegate to original command btn.clicked.connect(lambda checked=False, b=btn, cmd=command: self._handle_button_click(b, cmd)) # Add icon if provided if icon_name: icon_path = self.get_icon_path(icon_name) if icon_path: icon = QIcon(str(icon_path)) btn.setIcon(icon) btn.setIconSize(QSize(16, 16)) self.icon_buttons.append((btn, icon_name)) # Store button and icon name # Add tooltip if provided if tooltip: btn.setToolTip(tooltip) # Set primary class for the main call-to-action button if text == "One-Click Full Setup": btn.setProperty("class", "primary") group_layout.addWidget(btn) # Store button reference if requested if button_refs is not None and button_keys is not None and idx < len(button_keys): button_refs[button_keys[idx]] = btn return group def _handle_button_click(self, button, command): """Record last clicked button and invoke the original command.""" try: self._last_clicked_button = button command() except Exception as e: # If command failed immediately, do not leave spinner state queued self._last_clicked_button = None self.log(f"Error executing command: {e}", "error") def _show_spinner_safe(self, button): """Replace the given button's icon with a rotating spinner (UI thread).""" try: if button is None or not isinstance(button, QPushButton): return # If already spinning, do nothing if button in self._button_spinner_map: return # Determine size from existing icon size or button height current_size = button.iconSize() size = max(16, max(current_size.width(), current_size.height())) if current_size.isValid() else max(20, button.sizeHint().height() - 6) color = QColor('#8ff361') if self.dark_mode else QColor('#4caf50') # Prepare state state = { 'angle': 0, 'timer': QTimer(self), 'orig_icon': button.icon(), 'orig_size': current_size if current_size.isValid() else QSize(size, size), 'size': size, 'color': color, } def tick(): state['angle'] = (state['angle'] - 30) % 360 # Draw spinner pixmap pm = QPixmap(state['size'], state['size']) pm.fill(Qt.GlobalColor.transparent) painter = QPainter(pm) painter.setRenderHint(QPainter.RenderHint.Antialiasing) lw = max(2, int(state['size'] * 0.12)) rect = pm.rect().adjusted(lw, lw, -lw, -lw) pen = QPen(state['color']) pen.setWidth(lw) painter.setPen(pen) start_angle = int(state['angle'] * 16) span_angle = int(270 * 16) painter.drawArc(rect, start_angle, span_angle) painter.end() button.setIcon(QIcon(pm)) button.setIconSize(QSize(state['size'], state['size'])) t = state['timer'] t.setInterval(50) t.timeout.connect(tick) t.start() # Draw initial frame immediately tick() self._button_spinner_map[button] = state except Exception: pass def _hide_spinner_safe(self, button): """Restore the button's original icon (UI thread).""" try: state = self._button_spinner_map.pop(button, None) if state is None: return timer = state.get('timer') if timer: try: timer.stop() except Exception: pass orig_icon = state.get('orig_icon') orig_size = state.get('orig_size') if isinstance(button, QPushButton): if orig_icon is not None: button.setIcon(orig_icon) if orig_size is not None and orig_size.isValid(): button.setIconSize(orig_size) except Exception: pass def load_affinity_icon(self): """Download and load Affinity V3 icon""" try: icon_dir = Path.home() / ".local" / "share" / "icons" icon_dir.mkdir(parents=True, exist_ok=True) icon_path = icon_dir / "Affinity.svg" # Check if file exists and is valid SVG needs_download = True if icon_path.exists(): try: # Check if file is valid SVG (not HTML) with open(icon_path, 'r', encoding='utf-8') as f: first_line = f.readline().strip() # Valid SVG should start with ", ">") # Format message with better styling timestamp_html = f'[{timestamp}]' icon_html = f'{icon}' # Add subtle background for important messages if level in ["error", "success", "warning"]: full_message = f'
{timestamp_html} {icon_html} {message}
' else: full_message = f'
{timestamp_html} {icon_html} {message}
' self.log_text.append(full_message) self.log_text.verticalScrollBar().setValue( self.log_text.verticalScrollBar().maximum() ) # Write to log file (plain text, no HTML) if self.log_file: try: # Create a plain text version for the log file plain_message = f"[{timestamp}] [{level.upper()}] {message}" self.log_file.write(plain_message + "\n") self.log_file.flush() # Ensure it's written immediately except Exception: # If file write fails, continue without file logging pass def update_progress(self, value): """Update progress bar (thread-safe via signal)""" self.progress_signal.emit(value) def _update_progress_safe(self, value): """Thread-safe progress update handler (called from main thread)""" self.progress.setValue(int(value * 100)) def _update_progress_text_safe(self, text): """Thread-safe progress text update handler (called from main thread)""" self.progress_label.setText(text) def update_progress_text(self, text): """Update progress label text (thread-safe via signal)""" self.progress_text_signal.emit(text) def cancel_operation(self): """Cancel the current operation with confirmation""" # Ask for confirmation reply = QMessageBox.question( self, "Cancel Operation", f"Are you sure you want to cancel the current operation?\n\n" f"Operation: {self.current_operation or 'Unknown'}\n\n" f"Note: This may leave the installation in an incomplete state.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.operation_cancelled = True # Signal cancellation early so running commands can stop self.cancel_event.set() self.update_progress_text("Cancelling...") # Terminate any active subprocesses try: self.terminate_active_processes() except Exception: pass self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "warning") self.log("⚠ Operation cancelled by user", "warning") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n", "warning") self.update_progress_text("Operation cancelled") self.update_progress(0.0) # Reset progress bar self.cancel_btn.setVisible(False) self.operation_in_progress = False # Ensure spinner is restored on cancel try: if self._operation_button is not None: self.hide_spinner_signal.emit(self._operation_button) except Exception: pass def start_operation(self, operation_name): """Mark the start of an operation and show cancel button""" self.operation_cancelled = False self.cancel_event.clear() self.current_operation = operation_name self.operation_in_progress = True # Replace last clicked button with spinner (on UI thread) if self._last_clicked_button is not None: self._operation_button = self._last_clicked_button self.show_spinner_signal.emit(self._operation_button) if hasattr(self, 'cancel_btn'): self.cancel_btn.setVisible(True) def end_operation(self): """Mark the end of an operation: restore UI, reset progress, toggle cancel.""" self.operation_in_progress = False self.current_operation = None # Toggle cancel button off if hasattr(self, 'cancel_btn'): self.cancel_btn.setVisible(False) # Restore button icon (stop spinner) if self._operation_button is not None: self.hide_spinner_signal.emit(self._operation_button) self._operation_button = None self._last_clicked_button = None # Always restore progress bar/text to idle state self.update_progress(0.0) self.update_progress_text("Ready") def check_cancelled(self): """Check if operation was cancelled""" if self.operation_cancelled: self.end_operation() return True return False def show_message(self, title, message, msg_type="info"): """Show message box (thread-safe via signal)""" self.show_message_signal.emit(title, message, msg_type) def _show_message_safe(self, title, message, msg_type="info"): """Thread-safe message box handler (called from main thread)""" if msg_type == "error": QMessageBox.critical(self, title, message) elif msg_type == "warning": QMessageBox.warning(self, title, message) else: QMessageBox.information(self, title, message) def _request_sudo_password_safe(self): """Request sudo password from user (called from main thread)""" dialog = QDialog(self) dialog.setWindowTitle("Administrator Authentication Required") dialog.setMinimumWidth(400) dialog.setModal(True) # Ensure dialog is modal dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint) # Keep on top layout = QVBoxLayout(dialog) # Add explanation label label = QLabel( "This operation requires administrator privileges.\n\n" "Please enter your password to continue:" ) layout.addWidget(label) # Add password input password_input = QLineEdit() password_input.setEchoMode(QLineEdit.EchoMode.Password) password_input.setPlaceholderText("Enter your password") layout.addWidget(password_input) # Add buttons buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) # Allow Enter key to submit password_input.returnPressed.connect(dialog.accept) # Ensure dialog is visible and raised dialog.show() dialog.raise_() dialog.activateWindow() # Focus the password input password_input.setFocus() if dialog.exec() == QDialog.DialogCode.Accepted: self.sudo_password = password_input.text() else: self.sudo_password = None def get_sudo_password(self): """Get sudo password from user (thread-safe)""" # If we already have a validated password, return it if self.sudo_password_validated and self.sudo_password: return self.sudo_password # Request password from main thread self.sudo_password = None self.sudo_password_dialog_signal.emit() # Wait for password to be entered (with timeout) max_wait = 300 # 30 seconds timeout waited = 0 while self.sudo_password is None and waited < max_wait: time.sleep(0.1) waited += 1 return self.sudo_password def validate_sudo_password(self, password): """Validate sudo password by running a test command""" try: # Set up environment for sudo env = os.environ.copy() # Unset SUDO_ASKPASS to force sudo to read password from stdin via -S flag # This prevents errors when askpass programs (like ksshaskpass) don't exist env.pop('SUDO_ASKPASS', None) # Test the password with a harmless sudo command process = subprocess.Popen( ["sudo", "-S", "true"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, preexec_fn=os.setsid # Create new process group ) # Send password and wait for completion with timeout try: stdout, stderr = process.communicate(input=f"{password}\n", timeout=15) except subprocess.TimeoutExpired: # Timeout occurred, terminate the process and all its children try: # Try to kill the process group first if process.pid: try: pgid = os.getpgid(process.pid) os.killpg(pgid, signal.SIGTERM) time.sleep(0.5) # Force kill if still running if process.poll() is None: os.killpg(pgid, signal.SIGKILL) except (ProcessLookupError, OSError, AttributeError): # Process group doesn't exist or process already terminated process.kill() except Exception: # Fallback to simple kill try: process.kill() except Exception: pass try: process.communicate() except Exception: pass self.log("Password validation timed out - sudo may be waiting for input", "error") self.sudo_password_validated = False return False except Exception as e: # Other I/O errors - check if process succeeded anyway try: if process.poll() is None: process.wait(timeout=1) except Exception: pass # If return code is 0, validation succeeded despite the error if process.returncode == 0: self.sudo_password_validated = True return True self.log(f"Error validating sudo password: {e}", "error") self.sudo_password_validated = False return False if process.returncode == 0: self.sudo_password_validated = True return True else: # Check stderr for more details if stderr: error_msg = stderr.strip() if "incorrect password" in error_msg.lower() or "sorry" in error_msg.lower(): self.log("Incorrect password", "error") else: self.log(f"Password validation failed: {error_msg}", "error") else: self.log("Password validation failed", "error") self.sudo_password_validated = False return False except Exception as e: self.log(f"Error validating sudo password: {e}", "error") self.sudo_password_validated = False return False def _request_interactive_response_safe(self, prompt_text, default_response): """Request user response to interactive prompt (called from main thread)""" # Parse the prompt to determine type prompt_lower = prompt_text.lower() # Detect yes/no questions if any(pattern in prompt_lower for pattern in ["(y/n)", "[y/n]", "yes/no", "overwrite?"]): # Extract default from prompt default_yes = "y" in default_response.lower() if default_response else False reply = QMessageBox.question( self, "User Input Required", prompt_text, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes if default_yes else QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: self.interactive_response = "y\n" else: self.interactive_response = "n\n" else: # For other prompts, use input dialog response, ok = QInputDialog.getText( self, "User Input Required", prompt_text, QLineEdit.EchoMode.Normal, default_response or "" ) if ok: self.interactive_response = response + "\n" else: self.interactive_response = "\n" # Empty response (just Enter) self.waiting_for_response = False def get_interactive_response(self, prompt_text, default_response=""): """Get user response to interactive prompt (thread-safe)""" self.interactive_response = None self.waiting_for_response = True self.interactive_prompt_signal.emit(prompt_text, default_response) # Wait for response with timeout max_wait = 300 # 30 seconds waited = 0 while self.waiting_for_response and waited < max_wait: time.sleep(0.1) waited += 1 return self.interactive_response or "\n" def _show_question_dialog_safe(self, title, message, buttons): """Show question dialog (called from main thread)""" # Convert button list to QMessageBox buttons qbuttons = QMessageBox.StandardButton.NoButton for btn in buttons: if btn == "Yes": qbuttons |= QMessageBox.StandardButton.Yes elif btn == "No": qbuttons |= QMessageBox.StandardButton.No elif btn == "Retry": qbuttons |= QMessageBox.StandardButton.Retry elif btn == "Cancel": qbuttons |= QMessageBox.StandardButton.Cancel reply = QMessageBox.question(self, title, message, qbuttons) # Store response if reply == QMessageBox.StandardButton.Yes: self.question_dialog_response = "Yes" elif reply == QMessageBox.StandardButton.No: self.question_dialog_response = "No" elif reply == QMessageBox.StandardButton.Retry: self.question_dialog_response = "Retry" elif reply == QMessageBox.StandardButton.Cancel: self.question_dialog_response = "Cancel" else: self.question_dialog_response = "Cancel" self.waiting_for_question_response = False def show_question_dialog(self, title, message, buttons=["Yes", "No"]): """Show question dialog (thread-safe)""" self.question_dialog_response = None self.waiting_for_question_response = True self.question_dialog_signal.emit(title, message, buttons) # Wait for response with timeout max_wait = 300 # 30 seconds waited = 0 while self.waiting_for_question_response and waited < max_wait: time.sleep(0.1) waited += 1 return self.question_dialog_response or "Cancel" def _register_process(self, proc): """Track a running subprocess for potential cancellation.""" try: with self._process_lock: self._active_processes.add(proc) except Exception: pass def _unregister_process(self, proc): """Stop tracking a subprocess.""" try: with self._process_lock: self._active_processes.discard(proc) except Exception: pass def _terminate_process(self, proc): """Terminate a subprocess and its process group safely.""" try: # Try to terminate the whole process group first try: os.killpg(os.getpgid(proc.pid), signal.SIGTERM) except Exception: proc.terminate() # Wait briefly, then force kill if still alive try: proc.wait(timeout=2) except Exception: try: os.killpg(os.getpgid(proc.pid), signal.SIGKILL) except Exception: try: proc.kill() except Exception: pass finally: self._unregister_process(proc) def terminate_active_processes(self): """Terminate all active subprocesses started by this installer.""" try: with self._process_lock: procs = list(self._active_processes) for p in procs: self._terminate_process(p) except Exception: pass def run_command(self, command, check=True, shell=False, capture=True, env=None): """Execute shell command with GUI sudo password support and cancellation.""" try: # Convert command to list if it's a string if isinstance(command, str) and not shell: command = command.split() # Ensure command is a list if not isinstance(command, list): command = list(command) # Set up environment for non-interactive operation if env is None: env = os.environ.copy() # Force non-interactive mode for various tools env['DEBIAN_FRONTEND'] = 'noninteractive' env['NEEDRESTART_MODE'] = 'a' # Auto-restart services without asking env['DEBIAN_PRIORITY'] = 'critical' env['APT_LISTCHANGES_FRONTEND'] = 'none' env['LANG'] = 'C' # Use C locale to avoid encoding issues env['LC_ALL'] = 'C' # Check if this is a sudo command is_sudo = isinstance(command, list) and len(command) > 0 and command[0] == "sudo" # Unset SUDO_ASKPASS to force sudo to read password from stdin via -S flag # This prevents errors when askpass programs (like ksshaskpass) don't exist if is_sudo: env.pop('SUDO_ASKPASS', None) # Remove SUDO_ASKPASS if it exists if is_sudo: # Get password if needed max_attempts = 3 for attempt in range(max_attempts): if self.cancel_event.is_set(): return False, "", "Cancelled" password = self.get_sudo_password() if password is None: self.log("Authentication cancelled by user", "warning") return False, "", "Authentication cancelled" # Validate password first if not self.sudo_password_validated: if self.validate_sudo_password(password): self.log("Authentication successful", "success") break else: self.log("Authentication failed. Please try again.", "error") self.sudo_password = None self.sudo_password_validated = False if attempt == max_attempts - 1: return False, "", "Authentication failed after multiple attempts" else: break # Run command with password via stdin # Add -S flag to read password from stdin if not present # Make sure -S is right after "sudo" # Create a copy to avoid modifying the original command = list(command) if len(command) > 1: # Only add -S if it's not already in position 1 (right after sudo) # Don't remove -S that's part of the actual command (like pacman -S) if command[1] != "-S": # Insert -S right after "sudo" command.insert(1, "-S") else: # Only "sudo" in command, add -S command.append("-S") proc = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE if capture else None, stderr=subprocess.PIPE if capture else None, text=True, env=env, # Use the modified env that has SUDO_ASKPASS removed preexec_fn=os.setsid ) self._register_process(proc) try: # Send password to sudo via stdin using communicate() which handles stdin properly password_input = f"{self.sudo_password}\n" if capture: stdout_acc = "" stderr_acc = "" # Read output without timeout for long-running commands like package installation try: # Use communicate with input - this is the safest way out, err = proc.communicate(input=password_input, timeout=None) stdout_acc += out or "" stderr_acc += err or "" except subprocess.TimeoutExpired: # This shouldn't happen with timeout=None, but handle it just in case if self.cancel_event.is_set(): self._terminate_process(proc) return False, stdout_acc, "Cancelled" # Force read remaining output try: out, err = proc.communicate() stdout_acc += out or "" stderr_acc += err or "" except Exception: pass except Exception as e: # Catch all exceptions including "I/O operation on closed file" error_msg = str(e) error_type = type(e).__name__ # Check if process completed successfully despite the error try: if proc.poll() is None: # Process still running, wait a bit proc.wait(timeout=2) except Exception: pass # If return code is 0, the operation succeeded despite the exception if proc.returncode == 0: # Try to read any remaining output try: if proc.stdout and not proc.stdout.closed: remaining = proc.stdout.read() if remaining: stdout_acc += remaining except Exception: pass try: if proc.stderr and not proc.stderr.closed: remaining = proc.stderr.read() if remaining: stderr_acc += remaining except Exception: pass # Operation succeeded, return success return True, stdout_acc, stderr_acc # Only report error if return code indicates failure if "closed file" in error_msg.lower() or "I/O operation" in error_msg: # This is often a harmless error if the process succeeded if proc.returncode == 0: return True, stdout_acc, stderr_acc # If it failed, log it self.log(f"Error during command execution ({error_type}): {error_msg}", "error") else: self.log(f"Error during command execution ({error_type}): {error_msg}", "error") self._terminate_process(proc) return False, stdout_acc, stderr_acc or error_msg success = proc.returncode == 0 return success, stdout_acc, stderr_acc else: # No capture: send password and wait for completion try: proc.communicate(input=password_input, timeout=None) except Exception as e: # Catch all exceptions including "I/O operation on closed file" error_msg = str(e) # Check if process completed successfully despite the error try: if proc.poll() is None: proc.wait(timeout=2) except Exception: pass # If return code is 0, operation succeeded despite the exception if proc.returncode == 0: return True, "", "" # Only report error if return code indicates failure if "closed file" in error_msg.lower() or "I/O operation" in error_msg: # This is often a harmless error if the process succeeded if proc.returncode == 0: return True, "", "" # If it failed, log it self.log(f"Error during command execution: {error_msg}", "error") else: self.log(f"Error during command execution: {error_msg}", "error") self._terminate_process(proc) return False, "", error_msg except subprocess.TimeoutExpired: # This shouldn't happen with timeout=None, but handle it just in case if self.cancel_event.is_set(): self._terminate_process(proc) return False, "", "Cancelled" try: proc.communicate() except Exception: pass return proc.returncode == 0, "", "" finally: self._unregister_process(proc) else: # Non-sudo command, run normally with cancellation support proc = subprocess.Popen( command if not shell else (command if isinstance(command, str) else " ".join(command)), shell=shell, stdout=subprocess.PIPE if capture else None, stderr=subprocess.PIPE if capture else None, text=capture, env=env if env else os.environ.copy(), preexec_fn=os.setsid ) self._register_process(proc) try: if capture: stdout_acc = "" stderr_acc = "" while True: try: out, err = proc.communicate(timeout=0.1) stdout_acc += out or "" stderr_acc += err or "" break except subprocess.TimeoutExpired: if self.cancel_event.is_set(): self._terminate_process(proc) return False, stdout_acc, "Cancelled" continue success = proc.returncode == 0 return success, stdout_acc, stderr_acc else: while True: if self.cancel_event.is_set(): self._terminate_process(proc) return False, "", "Cancelled" if proc.poll() is not None: break time.sleep(0.1) return proc.returncode == 0, "", "" finally: self._unregister_process(proc) except Exception as e: return False, "", str(e) def run_command_streaming(self, command, env=None, progress_callback=None): """Execute command and stream output to log in real-time, cancellable. Also stores the full streamed text in self._last_stream_output_text for post-run heuristics.""" self._last_stream_output_text = "" try: if isinstance(command, str): command = command.split() # Set up environment for non-interactive operation if env is None: env = os.environ.copy() # Force non-interactive mode for various tools env['DEBIAN_FRONTEND'] = 'noninteractive' env['NEEDRESTART_MODE'] = 'a' # Auto-restart services without asking env['DEBIAN_PRIORITY'] = 'critical' env['APT_LISTCHANGES_FRONTEND'] = 'none' env['LANG'] = 'C' # Use C locale to avoid encoding issues env['LC_ALL'] = 'C' # Unset SUDO_ASKPASS if this is a sudo command is_sudo = isinstance(command, list) and len(command) > 0 and command[0] == "sudo" if is_sudo: env.pop('SUDO_ASKPASS', None) process = subprocess.Popen( command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, env=env, preexec_fn=os.setsid ) self._register_process(process) # Stream output line by line buffer = [] for line in iter(process.stdout.readline, ''): if self.cancel_event.is_set(): self._terminate_process(process) self._last_stream_output_text = "".join(buffer) return False if line: # Clean up the line and log it line = line.rstrip() if line: buffer.append(line + "\n") # Show important progress messages line_lower = line.lower() # Always show progress-related messages if any(keyword in line_lower for keyword in [ 'progress', 'downloading', 'installing', 'extracting', 'configuring', 'executing', 'running', 'done', 'complete', 'success', 'error', 'failed', 'warning', '%', 'mb', 'kb' ]): self.log(f" {line}", "info") # Try to extract progress percentage if callback provided if progress_callback: import re percent_match = re.search(r'(\d+)\s*%', line, re.IGNORECASE) if percent_match: try: percent = int(percent_match.group(1)) progress_callback(percent / 100.0) except (ValueError, TypeError): pass # Filter out very verbose Wine debug messages but keep important ones elif not any(skip in line_lower for skip in [ 'fixme:', 'trace:', 'debug:' ]): # Show other non-debug messages self.log(f" {line}", "info") process.wait() self._last_stream_output_text = "".join(buffer) return process.returncode == 0 except Exception as e: self.log(f"Error running command: {e}", "error") return False finally: try: self._unregister_process(process) except Exception: pass def _to_windows_path(self, unix_path, env=None): """Convert a UNIX path to a Windows path for Wine 'start' using winepath. Falls back to Z: drive mapping if winepath is unavailable.""" try: winepath_bin = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winepath" if winepath_bin.exists(): success, stdout, _ = self.run_command([str(winepath_bin), "-w", str(unix_path)], check=False, env=env, capture=True) if success and stdout: return stdout.strip().splitlines()[-1] except Exception: pass # Fallback: Z: mapping p = str(unix_path) if p.startswith("/"): win = "Z:" + p else: win = p return win.replace("/", "\\") def _has_installer_activity(self, installer_file: Path) -> bool: """Heuristics to detect installer activity: - Check for Wine processes referencing installer/common names - If wmctrl is available, check for visible windows with class/name containing wine/setup/installer """ try: # Process-based heuristic patterns = [installer_file.name.lower(), "setup", "msiexec", "install", ".msi", ".exe"] success, stdout, _ = self.run_command(["ps", "-eo", "pid,command"], check=False, capture=True) if success and stdout: text = stdout.lower() if ("wine" in text or "wineserver" in text) and any(pat in text for pat in patterns): return True # Window-based heuristic (wmctrl) wmctrl = shutil.which("wmctrl") if wmctrl: ok, wout, _ = self.run_command([wmctrl, "-lx"], check=False, capture=True) if ok and wout: w = wout.lower() # Examples: 'wine.wine explorer.exe', 'setup.exe', 'affinity' if "wine" in w and any(pat in w for pat in patterns): return True except Exception: pass return False def _run_installer_and_capture(self, installer_file: Path, env: dict, label: str = "installer"): """Run a Windows installer under Wine, stream logs, and wait robustly until it exits. Strategy: 1) Try 'wine start /wait /unix ' 2) If it exits too quickly or returns non-zero with no activity, try 'wine ' 3) After launch, wait on 'wineserver -w' to ensure child processes finish (cancellable) """ wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" attempts = [ [str(wine), "start", "/wait", "/unix", str(installer_file)], [str(wine), str(installer_file)], ] for idx, cmd in enumerate(attempts, start=1): try: cmd_str = " ".join(shlex.quote(c) for c in cmd) self.log(f"Running ({label}) attempt {idx}: {cmd_str}", "info") t0 = time.time() ok = self.run_command_streaming(cmd, env=env) dt = time.time() - t0 # If it "succeeded" unrealistically fast, poll briefly for activity or window if ok and dt < 5.0: self.log(f"{label.capitalize()} attempt {idx} returned quickly ({dt:.2f}s). Polling for activity...", "warning") for _ in range(30): # ~3s if self.check_cancelled(): return False if self._has_installer_activity(installer_file): break time.sleep(0.1) else: ok = False # Also verify there was some wine activity (best-effort heuristic) if ok and not self._has_installer_activity(installer_file): # As a last signal, check stream output for obvious errors txt = (self._last_stream_output_text or "").lower() error_markers = ["err:", "cannot find", "bad exe", "failed", "error:", "no such file", "unable to load"] if any(m in txt for m in error_markers): ok = False if ok: self.log("Waiting for Wine processes to finish (wineserver -w)...", "info") # Extended wait; cancellable via run_command loop env_wait = env.copy() if env else os.environ.copy() env_wait["WINEPREFIX"] = self.directory self.run_command(["wineserver", "-w"], check=False, capture=False, env=env_wait) return True if self.check_cancelled(): return False self.log(f"{label.capitalize()} attempt {idx} did not run (ok={ok}, dt={dt:.2f}s). Trying fallback...", "warning") except Exception as e: self.log(f"Error launching {label} attempt {idx}: {e}", "error") return False def run_command_interactive(self, command, env=None): """Execute command and handle interactive prompts via GUI, cancellable.""" try: if isinstance(command, str): command = command.split() # Set up environment for non-interactive operation if env is None: env = os.environ.copy() # Force non-interactive mode for various tools env['DEBIAN_FRONTEND'] = 'noninteractive' env['NEEDRESTART_MODE'] = 'a' env['DEBIAN_PRIORITY'] = 'critical' env['APT_LISTCHANGES_FRONTEND'] = 'none' env['LANG'] = 'C' env['LC_ALL'] = 'C' # Check if this is a sudo command is_sudo = isinstance(command, list) and len(command) > 0 and command[0] == "sudo" # Unset SUDO_ASKPASS to force sudo to read password from stdin via -S flag # This prevents errors when askpass programs (like ksshaskpass) don't exist if is_sudo: env.pop('SUDO_ASKPASS', None) if is_sudo: # Get password if needed password = self.get_sudo_password() if password is None: self.log("Authentication cancelled by user", "warning") return False, "", "Authentication cancelled" # Validate password if not already validated if not self.sudo_password_validated: if not self.validate_sudo_password(password): self.log("Authentication failed", "error") return False, "", "Authentication failed" # Add -S flag if not present # Create a copy to avoid modifying the original command = list(command) if len(command) > 1: if command[1] != "-S": # Remove -S if it exists elsewhere (safely) while "-S" in command: command.remove("-S") # Insert -S right after "sudo" command.insert(1, "-S") else: # Only "sudo" in command, add -S command.append("-S") # Start process process = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=env, preexec_fn=os.setsid ) self._register_process(process) # If sudo, send password first if is_sudo and self.sudo_password: try: process.stdin.write(f"{self.sudo_password}\n") process.stdin.flush() except Exception: pass # Read output and detect prompts output_lines = [] error_lines = [] import select while True: if self.cancel_event.is_set(): self._terminate_process(process) return False, "", "Cancelled" # Check if process has finished if process.poll() is not None: # Read any remaining output remaining_out = process.stdout.read() remaining_err = process.stderr.read() if remaining_out: output_lines.append(remaining_out) if remaining_err: error_lines.append(remaining_err) break # Try to read from stdout with timeout try: # Use select to check if data is available (Unix-like systems) import sys if hasattr(select, 'select'): readable, _, _ = select.select([process.stdout, process.stderr], [], [], 0.1) for stream in readable: line = stream.readline() if line: if stream == process.stdout: output_lines.append(line) self.log(f" {line.rstrip()}", "info") else: error_lines.append(line) # Detect interactive prompts line_lower = line.lower() if any(pattern in line_lower for pattern in [ "overwrite?", "(y/n)", "[y/n]", "yes/no", "continue?", "proceed?", "replace?" ]): # Interactive prompt detected! self.log(f"Interactive prompt detected: {line.rstrip()}", "warning") # Extract default response if present default = "" if "(y/n)" in line_lower: # Check which is capitalized if "(Y/n)" in line: default = "y" elif "(y/N)" in line: default = "n" # Get user response via GUI response = self.get_interactive_response(line.rstrip(), default) # Send response to process if process.stdin: process.stdin.write(response) process.stdin.flush() except Exception as e: self.log(f"Error reading process output: {e}", "warning") time.sleep(0.1) # Get return code return_code = process.wait() stdout_text = "".join(output_lines) stderr_text = "".join(error_lines) return return_code == 0, stdout_text, stderr_text except Exception as e: self.log(f"Error in interactive command: {e}", "error") return False, "", str(e) finally: try: self._unregister_process(process) except Exception: pass def check_command(self, cmd): """Check if command exists""" return shutil.which(cmd) is not None def detect_distro(self): """Detect Linux distribution""" try: with open("/etc/os-release", "r") as f: content = f.read() for line in content.split("\n"): if line.startswith("ID="): self.distro = line.split("=", 1)[1].strip().strip('"') elif line.startswith("VERSION_ID="): self.distro_version = line.split("=", 1)[1].strip().strip('"') # Normalize "pika" to "pikaos" if detected if self.distro == "pika": self.distro = "pikaos" # Normalize "pop" to "pop" if detected if self.distro == "pop": self.distro = "pop" return True except Exception as e: self.log(f"Error detecting distribution: {e}", "error") return False def _ensure_icons_directory(self): """Ensure icons directory exists, download from GitHub if missing""" try: # Always use the standard location in user's config directory # This ensures icons are available even when script is piped from curl script_dir = Path.home() / ".config" / "AffinityOnLinux" / "AffinityScripts" script_dir.mkdir(parents=True, exist_ok=True) icons_dir = script_dir / "icons" # Ensure icons directory exists icons_dir.mkdir(parents=True, exist_ok=True) # List of UI theme icons to download from GitHub # Note: Application icons (Affinity.png, etc.) are downloaded elsewhere # These are just the UI button icons needed for the installer interface icon_files = [ # UI theme icons - these are the ones actually used by the installer buttons # The application icons are handled by setup_wine function ] # Check which icons are missing missing_icons = [] for local_name, github_path in icon_files: icon_path = icons_dir / local_name if not icon_path.exists(): missing_icons.append((local_name, github_path)) # Only download if there are missing icons if not missing_icons: return # All icons already exist # Download icons silently (no log messages) base_url = "https://raw.githubusercontent.com/seapear/AffinityOnLinux/main/" for local_name, github_path in missing_icons: icon_path = icons_dir / local_name try: # Check if github_path is a full URL (for user-attachments) or relative path if github_path.startswith("http://") or github_path.startswith("https://"): icon_url = github_path else: icon_url = base_url + github_path # Use urlretrieve with better error handling urllib.request.urlretrieve(icon_url, str(icon_path)) except Exception: # Silently fail - icons are not critical for functionality pass except Exception: # Silently fail - icons are not critical for functionality pass def detect_gpus(self): """Detect available GPUs in the system""" gpus = [] # Get lspci output once lspci_success, lspci_stdout, _ = self.run_command(["lspci"], check=False, capture=True) if not lspci_success or not lspci_stdout: # If lspci fails, return auto option only gpus.append({ "type": "auto", "name": "Auto (System Default)", "index": 0, "id": "auto" }) return gpus # Parse lspci output to find actual GPU devices # Look for VGA, 3D, or Display controller entries gpu_lines = [] for line in lspci_stdout.split('\n'): line_lower = line.lower() # Check if this is a graphics/display device if any(keyword in line_lower for keyword in ['vga', '3d', 'display controller', 'graphics']): gpu_lines.append(line) # Process each GPU line to extract type and model for line in gpu_lines: line_lower = line.lower() # Extract model name (everything after the last colon) if ':' in line: model = line.split(':')[2].strip() if len(line.split(':')) > 2 else "Unknown GPU" else: model = "Unknown GPU" # Determine GPU type gpu_type = None gpu_id = None if 'nvidia' in line_lower: gpu_type = "nvidia" # Count existing nvidia GPUs to get index nvidia_count = sum(1 for gpu in gpus if gpu["type"] == "nvidia") gpu_id = f"nvidia_{nvidia_count}" elif 'amd' in line_lower or 'radeon' in line_lower or 'amd/ati' in line_lower: gpu_type = "amd" amd_count = sum(1 for gpu in gpus if gpu["type"] == "amd") gpu_id = f"amd_{amd_count}" elif 'intel' in line_lower: gpu_type = "intel" intel_count = sum(1 for gpu in gpus if gpu["type"] == "intel") gpu_id = f"intel_{intel_count}" # Only add if we identified a GPU type if gpu_type: gpus.append({ "type": gpu_type, "name": model, "index": len([g for g in gpus if g["type"] == gpu_type]), "id": gpu_id }) # Always add "Auto" option as the first choice gpus.insert(0, { "type": "auto", "name": "Auto (System Default)", "index": 0, "id": "auto" }) return gpus def get_gpu_env_vars(self, gpu_id=None): """Get environment variables for GPU selection""" if gpu_id is None: # Load from saved preference gpu_config_file = Path(self.directory) / ".gpu_config" if gpu_config_file.exists(): try: with open(gpu_config_file, 'r') as f: gpu_id = f.read().strip() except Exception: gpu_id = "auto" else: gpu_id = "auto" env_vars = [] if gpu_id == "auto" or not gpu_id: # No specific GPU selection - use system default return "" if gpu_id.startswith("nvidia_"): # NVIDIA GPU selection env_vars.append("__NV_PRIME_RENDER_OFFLOAD=1") env_vars.append("__GLX_VENDOR_LIBRARY_NAME=nvidia") # Also set for Vulkan env_vars.append("__VK_LAYER_NV_optimus=NVIDIA_only") elif gpu_id.startswith("amd_"): # AMD discrete GPU (using DRI_PRIME) index = int(gpu_id.split("_")[1]) if "_" in gpu_id else 1 env_vars.append(f"DRI_PRIME={index}") elif gpu_id.startswith("intel_"): # Intel GPU (usually integrated, use DRI_PRIME=0) env_vars.append("DRI_PRIME=0") if env_vars: return " ".join(env_vars) + " " return "" def configure_gpu_selection(self): """Configure GPU selection for dual GPU setups""" gpus = self.detect_gpus() if len(gpus) <= 1: self.show_message( "GPU Selection", "Only one GPU detected or no GPUs found.\n\n" "GPU selection is only needed for dual GPU setups.\n" "Your system will use the default GPU automatically.", "info" ) return # Load current selection gpu_config_file = Path(self.directory) / ".gpu_config" current_gpu = "auto" if gpu_config_file.exists(): try: with open(gpu_config_file, 'r') as f: current_gpu = f.read().strip() except Exception: pass # Create dialog for GPU selection dialog = QDialog(self) dialog.setWindowTitle("GPU Selection for Affinity Applications") dialog.setMinimumWidth(500) layout = QVBoxLayout(dialog) label = QLabel( "Select which GPU to use for Affinity applications:\n\n" "This is useful for dual GPU setups (e.g., Intel + NVIDIA, AMD + NVIDIA)." ) layout.addWidget(label) # Create radio buttons for each GPU button_group = QButtonGroup(dialog) radio_buttons = [] # Add "Auto" option first auto_radio = QRadioButton("Auto (System Default)") auto_radio.setChecked(current_gpu == "auto") button_group.addButton(auto_radio, -1) layout.addWidget(auto_radio) radio_buttons.append(("auto", auto_radio)) # Add detected GPUs for gpu in gpus: if gpu["id"] != "auto": # Skip if it's the auto placeholder gpu_label = f"{gpu['name']} ({gpu['type'].upper()})" radio = QRadioButton(gpu_label) radio.setChecked(current_gpu == gpu["id"]) button_group.addButton(radio, gpus.index(gpu)) layout.addWidget(radio) radio_buttons.append((gpu["id"], radio)) # Add buttons buttons = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() == QDialog.DialogCode.Accepted: selected_id = None for gpu_id, radio in radio_buttons: if radio.isChecked(): selected_id = gpu_id break if selected_id: # Save selection try: with open(gpu_config_file, 'w') as f: f.write(selected_id) gpu_name = next((gpu["name"] for gpu in gpus if gpu["id"] == selected_id), "Auto") self.log(f"GPU selection saved: {gpu_name}", "success") # Update existing desktop entries self.update_existing_desktop_entries() self.show_message( "GPU Selection Saved", f"Selected GPU: {gpu_name}\n\n" "All existing desktop entries have been updated with the new GPU configuration.", "info" ) except Exception as e: self.log(f"Failed to save GPU selection: {e}", "error") def update_existing_desktop_entries(self): """Update existing desktop entries with current GPU configuration""" desktop_dir = Path.home() / ".local" / "share" / "applications" if not desktop_dir.exists(): return # Get current GPU environment variables gpu_env = self.get_gpu_env_vars() directory_str = str(self.directory).rstrip("/") # Find all Affinity desktop entries affinity_desktop_files = [ desktop_dir / "AffinityPhoto.desktop", desktop_dir / "AffinityDesigner.desktop", desktop_dir / "AffinityPublisher.desktop", desktop_dir / "Affinity.desktop" ] updated_count = 0 for desktop_file in affinity_desktop_files: if not desktop_file.exists(): continue try: # Read the desktop file with open(desktop_file, 'r') as f: lines = f.readlines() # Find and update the Exec line new_lines = [] exec_updated = False for line in lines: if line.startswith("Exec="): # Parse the existing Exec line exec_content = line[5:].strip() # Remove "Exec=" prefix # Use regex to extract the app path (everything after wine, typically in quotes or ending with .exe) # Pattern 1: Find app path in quotes after wine quoted_path_match = re.search(r'wine\s+"([^"]+)"', exec_content) if quoted_path_match: app_path = quoted_path_match.group(1) else: # Pattern 2: Find app path without quotes (look for .exe) exe_match = re.search(r'wine\s+([^\s]+\.exe[^\s]*)', exec_content) if exe_match: app_path = exe_match.group(1) else: # Pattern 3: Find any path containing .exe exe_match = re.search(r'([^\s]+\.exe[^\s]*)', exec_content) if exe_match: app_path = exe_match.group(1).strip('"') else: # Fallback: try to extract from the end parts = exec_content.split() for part in reversed(parts): if ".exe" in part or "drive_c" in part: app_path = part.strip('"') break # Get wine path (standard location) wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" wine_path = str(wine) # Rebuild Exec line with new GPU env vars exec_line = f'Exec=env WINEPREFIX={directory_str}' if gpu_env: exec_line += f' {gpu_env}' exec_line += f' {wine_path}' if app_path: # Quote the app path if it contains spaces or special characters if ' ' in app_path or not app_path.startswith('/'): exec_line += f' "{app_path}"' else: exec_line += f' {app_path}' else: # If we couldn't parse app_path, log a warning but still update GPU env self.log(f"Warning: Could not parse app path from {desktop_file.name}, updating GPU env only", "warning") new_lines.append(exec_line + "\n") exec_updated = True else: new_lines.append(line) # Write back if Exec line was updated if exec_updated: with open(desktop_file, 'w') as f: f.writelines(new_lines) updated_count += 1 self.log(f"Updated desktop entry: {desktop_file.name}", "info") except Exception as e: self.log(f"Failed to update {desktop_file.name}: {e}", "warning") if updated_count > 0: self.log(f"Updated {updated_count} desktop entry/entries with new GPU configuration", "success") else: self.log("No desktop entries found to update", "info") def format_distro_name(self, distro=None): """Format distribution name for display with proper capitalization""" if distro is None: distro = self.distro # Map lowercase distro IDs to proper display names distro_names = { "arch": "Arch", "cachyos": "CachyOS", "endeavouros": "EndeavourOS", "xerolinux": "XeroLinux", "fedora": "Fedora", "nobara": "Nobara", "opensuse-tumbleweed": "openSUSE Tumbleweed", "opensuse-leap": "openSUSE Leap", "pikaos": "PikaOS", "pop": "Pop!_OS", "ubuntu": "Ubuntu", "linuxmint": "Linux Mint", "zorin": "Zorin OS", "debian": "Debian", "manjaro": "Manjaro" } return distro_names.get(distro.lower(), distro.title()) def download_file(self, url, output_path, description=""): """Download file with progress tracking""" try: # Check if cancelled before starting if self.check_cancelled(): return False self.log(f"Downloading {description}...", "info") # Create request with proper headers req = urllib.request.Request(url) req.add_header('User-Agent', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36') req.add_header('Accept', '*/*') # Use urlopen for better header support and manual progress tracking with urllib.request.urlopen(req) as response: total_size = int(response.headers.get('Content-Length', 0)) downloaded = 0 block_size = 8192 with open(output_path, 'wb') as out_file: while True: # Check for cancellation during download if self.check_cancelled(): self.log(f"Download of {description} cancelled", "warning") return False chunk = response.read(block_size) if not chunk: break out_file.write(chunk) downloaded += len(chunk) if total_size > 0: percent = min(100, (downloaded * 100) // total_size) self.update_progress(percent / 100.0) self.update_progress(1.0) return True except urllib.error.HTTPError as e: self.log(f"Download failed: HTTP {e.code} {e.reason}", "error") if e.code == 404: self.log(f" URL may be expired or invalid: {url[:80]}...", "warning") return False except Exception as e: self.log(f"Download failed: {e}", "error") return False def start_initialization(self): """Start initialization process""" threading.Thread(target=self.initialize, daemon=True).start() def initialize(self): """Initialize installer""" self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Affinity Linux Installer - Initialization", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Detect distribution self.update_progress(0.1) if not self.detect_distro(): self.log("Failed to detect distribution. Exiting.", "error") return self.log(f"Detected distribution: {self.format_distro_name()} {self.distro_version or ''}", "success") self.update_progress(0.2) # Check dependencies if not self.check_dependencies(): return # Setup Wine self.update_progress(0.5) self.setup_wine() # Show main menu QTimer.singleShot(0, self.show_main_menu) def one_click_setup(self): """One-click full setup: detects distro, installs deps, sets up Wine, installs Winetricks deps""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("One-Click Full Setup", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.log("This will automatically:", "info") self.log(" 1. Detect your Linux distribution", "info") self.log(" 2. Check and install system dependencies", "info") self.log(" 3. Setup Wine environment (download and configure)", "info") self.log(" 4. Install Winetricks dependencies (.NET, fonts, etc.)", "info") self.log(" 5. Prompt you to install an Affinity application\n", "info") threading.Thread(target=self._one_click_setup_thread, daemon=True).start() def _one_click_setup_thread(self): """One-click setup in background thread""" self.start_operation("One-Click Full Setup") # Ensure patcher files are available self.ensure_patcher_files() # Ask about OpenCL support (only if not already configured) opencl_config_file = Path(self.directory) / ".opencl_enabled" if not opencl_config_file.exists(): opencl_reply = self.show_question_dialog( "Enable OpenCL Support?", "OpenCL (Open Computing Language) enables hardware acceleration for certain features in Affinity applications, " "which can improve performance for tasks like image processing, filters, and effects.\n\n" "This will download and configure vkd3d-proton, which provides OpenCL support through Vulkan.\n\n" "Would you like to enable OpenCL support?\n\n" "Note: You can change this setting later if needed.", ["Yes", "No"] ) if opencl_reply == "Yes": self.enable_opencl = True self.log("OpenCL support will be enabled", "info") else: self.enable_opencl = False self.log("OpenCL support will be disabled", "info") # Save OpenCL preference try: with open(opencl_config_file, 'w') as f: f.write("1" if self.enable_opencl else "0") except Exception as e: self.log(f"Failed to save OpenCL preference: {e}", "warning") else: # Load existing preference self.enable_opencl = self.is_opencl_enabled() if self.enable_opencl: self.log("OpenCL support is enabled (from previous setup)", "info") else: self.log("OpenCL support is disabled (from previous setup)", "info") if self.check_cancelled(): return # Step 1: Detect distribution self.update_progress_text("Step 1/4: Detecting Linux distribution...") self.update_progress(0.05) if self.check_cancelled(): return if not self.detect_distro(): self.log("Failed to detect distribution. Cannot continue.", "error") self.update_progress_text("Ready") self.end_operation() return self.log(f"Detected distribution: {self.format_distro_name()} {self.distro_version or ''}", "success") if self.check_cancelled(): return # Step 2: Check and install dependencies self.update_progress_text("Step 2/4: Checking and installing system dependencies...") self.update_progress(0.15) if self.check_cancelled(): return if not self.check_dependencies(): self.log("Dependency check failed. Please resolve issues and try again.", "error") self.update_progress_text("Ready") self.end_operation() # Show retry dialog reply = self.show_question_dialog( "Dependency Check Failed", "Dependency check failed. Please resolve issues and try again.\n\n" "Would you like to retry the dependency check?", ["Yes", "No"] ) if reply == "Yes": # Retry dependency check return self._one_click_setup_thread() else: self.end_operation() return if self.check_cancelled(): return # Step 3: Setup Wine environment (this includes winetricks dependencies via configure_wine) self.update_progress_text("Step 3/4: Setting up Wine environment...") self.update_progress(0.40) if self.check_cancelled(): return self.setup_wine() if self.check_cancelled(): return # Step 4: Install Affinity v3 settings to enable settings saving self.update_progress_text("Step 4/4: Installing Affinity v3 settings...") self.update_progress(0.90) if self.check_cancelled(): return self.log("Installing Affinity v3 settings files...", "info") self._install_affinity_settings_thread() if self.check_cancelled(): return # Complete! self.update_progress(1.0) self.update_progress_text("Setup Complete!") self.log("\n✓ Full setup completed!", "success") self.log("You can now install Affinity applications using the buttons above.", "info") # End operation self.end_operation() # Refresh installation status to update button states QTimer.singleShot(100, self.check_installation_status) # Ask if user wants to install an Affinity app self.prompt_affinity_install_signal.emit() def _prompt_affinity_install(self): """Prompt user to install an Affinity application""" reply = QMessageBox.question( self, "Install Affinity Application", "Setup is complete!\n\nWould you like to install an Affinity application now?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: # Show a dialog to select which app dialog = QDialog(self) dialog.setWindowTitle("Select Affinity Application") dialog.setModal(True) layout = QVBoxLayout(dialog) label = QLabel("Which Affinity application would you like to install?") layout.addWidget(label) button_group = QButtonGroup() apps = [ ("Add", "Affinity (Unified)"), ("Photo", "Affinity Photo"), ("Designer", "Affinity Designer"), ("Publisher", "Affinity Publisher") ] radio_buttons = {} for idx, (app_code, app_name) in enumerate(apps): radio = QRadioButton(app_name) if app_code == "Add": radio.setChecked(True) button_group.addButton(radio, idx) radio_buttons[idx] = app_code layout.addWidget(radio) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() == QDialog.DialogCode.Accepted: checked_id = button_group.checkedId() if checked_id >= 0 and checked_id in radio_buttons: app_code = radio_buttons[checked_id] self.install_application_signal.emit(app_code) def install_application(self, app_code): """Install an Affinity application - asks user if they want to download or provide their own exe""" app_names = { "Add": "Affinity (Unified)", "Photo": "Affinity Photo", "Designer": "Affinity Designer", "Publisher": "Affinity Publisher" } display_name = app_names.get(app_code, "Affinity") # Check if Wine is set up wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") return # Ask user if they want to download or provide their own exe dialog = QDialog(self) dialog.setWindowTitle(f"Install {display_name}") dialog.setModal(True) dialog.setMinimumWidth(400) layout = QVBoxLayout(dialog) label = QLabel(f"How would you like to get the {display_name} installer?") layout.addWidget(label) button_group = QButtonGroup() download_radio = QRadioButton("Download from Affinity Studio (automatic)") download_radio.setChecked(True) custom_radio = QRadioButton("Provide my own installer file (.exe)") button_group.addButton(download_radio, 0) button_group.addButton(custom_radio, 1) layout.addWidget(download_radio) layout.addWidget(custom_radio) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() == QDialog.DialogCode.Accepted: checked_id = button_group.checkedId() installer_path = None if checked_id == 0: # Download # Download the installer in background, then install self.log(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log(f"Downloading {display_name} Installer", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") download_url = "https://downloads.affinity.studio/Affinity%20x64.exe" download_dir = Path.home() / ".cache" / "affinity-installer" download_dir.mkdir(parents=True, exist_ok=True) installer_path = download_dir / "Affinity-x64.exe" self.start_operation(f"Install {display_name}") threading.Thread( target=self._download_then_install, args=(app_code, display_name, download_url, str(installer_path)), daemon=True ).start() return else: # Provide own file # Open file dialog to select .exe self.log(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log(f"Custom Installer for {display_name}", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.log("Please select the installer .exe file...", "info") installer_path, _ = QFileDialog.getOpenFileName( self, f"Select {display_name} Installer", "", "Executable files (*.exe);;All files (*.*)" ) if not installer_path: self.log("Installation cancelled.", "warning") return # QFileDialog returns a string, but we'll normalize it installer_path = Path(installer_path) # Verify file exists and convert to string for run_installation installer_path_str = str(installer_path) if not Path(installer_path_str).exists(): self.log(f"Installer file not found: {installer_path_str}", "error") return # Start operation and installation in background thread self.start_operation(f"Install {display_name}") threading.Thread( target=self._run_installation_entry, args=(app_code, installer_path_str), daemon=True ).start() def _download_then_install(self, app_code, display_name, download_url, installer_path_str): """Download installer then run installation (runs in background).""" try: self.log(f"Downloading from: {download_url}", "info") if not self.download_file(download_url, installer_path_str, f"{display_name} installer"): self.log("Download failed. Please try providing your own installer file.", "error") self.show_message( "Download Failed", "Failed to download the installer.\n\nYou can download it manually from:\nhttps://downloads.affinity.studio/Affinity%20x64.exe\n\nThen use 'Provide my own installer file' option.", "error" ) # End the operation because run_installation won't be called self.end_operation() return self.log(f"Download completed: {installer_path_str}", "success") # Proceed to install (will end operation in wrapper) self._run_installation_entry(app_code, installer_path_str) except Exception as e: self.log(f"Error during download+install: {e}", "error") self.end_operation() def check_dependencies(self): """Check and install dependencies""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Dependency Verification", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.update_progress_text("Checking dependencies...") self.update_progress(0.0) # Show unsupported warning if self.distro in ["ubuntu", "linuxmint", "zorin"]: self.show_unsupported_warning() missing = [] deps = ["wine", "winetricks", "wget", "curl", "tar", "jq"] total_checks = len(deps) + 3 # +3 for archive tools, zstd, and dotnet for idx, dep in enumerate(deps): progress = (idx + 1) / total_checks * 0.5 # Use first 50% for checking self.update_progress(progress) self.update_progress_text(f"Checking {dep}...") if self.check_command(dep): self.log(f"{dep} is installed", "success") else: self.log(f"{dep} is not installed", "error") missing.append(dep) # Check for either 7z or unzip (both can extract archives) progress = (len(deps) + 1) / total_checks * 0.5 self.update_progress(progress) self.update_progress_text("Checking archive tools...") if not self.check_command("7z") and not self.check_command("unzip"): self.log("Neither 7z nor unzip is installed (at least one is required)", "error") missing.append("7z or unzip") else: if self.check_command("7z"): self.log("7z is installed", "success") else: self.log("unzip is installed (will be used instead of 7z)", "success") # Check zstd progress = (len(deps) + 2) / total_checks * 0.5 self.update_progress(progress) self.update_progress_text("Checking zstd...") if not (self.check_command("unzstd") or self.check_command("zstd")): self.log("zstd or unzstd is not installed", "error") missing.append("zstd") else: self.log("zstd support is available", "success") # Check .NET SDK (optional but recommended for Affinity v3 settings fix) progress = (len(deps) + 3) / total_checks * 0.5 self.update_progress(progress) self.update_progress_text("Checking .NET SDK...") if not self.check_dotnet_sdk(): self.log(".NET SDK is not installed (optional - needed for Affinity v3 settings fix)", "warning") missing.append("dotnet-sdk") else: self.log(".NET SDK is installed", "success") # Handle unsupported distributions - show warning and allow retry if self.distro in ["ubuntu", "linuxmint", "pop", "zorin"]: if missing: self.log("\n" + "="*80, "error") self.log("⚠️ WARNING: UNSUPPORTED DISTRIBUTION", "error") self.log("="*80, "error") self.log("\nMissing dependencies detected.", "error") self.log("This script will NOT auto-install for unsupported distributions.", "error") self.log("Please install the required dependencies manually.", "warning") self.log(f"Missing: {', '.join(missing)}", "warning") # Show dialog asking user to install and retry reply = self.show_question_dialog( "Unsupported Distribution - Missing Dependencies", f"⚠️ WARNING: UNSUPPORTED DISTRIBUTION\n\n" f"Missing dependencies: {', '.join(missing)}\n\n" f"This script will NOT auto-install for unsupported distributions.\n" f"Please install the required dependencies manually.\n\n" f"Click 'Retry' after installing dependencies, or 'Cancel' to exit.", ["Retry", "Cancel"] ) if reply == "Retry": # Re-check dependencies return self.check_dependencies() else: return False else: self.log("\nAll dependencies installed, but you are on an unsupported distribution.", "warning") self.log("No support will be provided if issues arise.", "warning") # Install missing dependencies (only for supported distributions) if missing and self.distro not in ["ubuntu", "linuxmint", "pop", "zorin"]: self.log(f"\nInstalling missing dependencies: {', '.join(missing)}", "info") self.update_progress_text(f"Installing {len(missing)} missing packages...") self.update_progress(0.5) # Start second half of progress # Request password before attempting installation self.log("Administrator privileges required for package installation.", "info") self.update_progress_text("Requesting administrator password...") # Try to get and validate password (with retries) max_password_attempts = 3 password_valid = False for password_attempt in range(max_password_attempts): password = self.get_sudo_password() if password is None: self.log("Password entry cancelled. Cannot install dependencies.", "error") self.update_progress_text("Dependency installation cancelled") return False # Validate password before proceeding if not self.sudo_password_validated: self.log(f"Validating password... (attempt {password_attempt + 1}/{max_password_attempts})", "info") if self.validate_sudo_password(password): self.log("Password validated successfully.", "success") password_valid = True break else: if password_attempt < max_password_attempts - 1: self.log("Password validation failed. Please try again.", "error") # Clear the password to force a new dialog on next get_sudo_password call self.sudo_password = None self.sudo_password_validated = False # Wait a moment for user to see the error message time.sleep(1) else: self.log("Password validation failed after multiple attempts.", "error") return False else: # Password already validated password_valid = True break if not password_valid: self.log("Could not validate password. Cannot install dependencies.", "error") self.update_progress_text("Dependency installation cancelled") return False if not self.install_dependencies(): self.update_progress_text("Dependency installation failed") return False self.update_progress(1.0) self.update_progress_text("All dependencies installed") self.log("\n✓ All required dependencies are installed!", "success") return True def show_unsupported_warning(self): """Display unsupported distribution warning""" self.log("\n" + "="*80, "warning") self.log("⚠️ WARNING: UNSUPPORTED DISTRIBUTION", "error") self.log("="*80, "warning") self.log(f"\nYOU ARE ON YOUR OWN!", "error") self.log(f"\nThe distribution ({self.format_distro_name()}) is OUT OF DATE", "warning") self.log("and the script will NOT be built around it.", "warning") self.log("\nFor a modern, stable Linux experience, please consider:", "info") self.log(" • PikaOS 4", "success") self.log(" • CachyOS", "success") self.log(" • Nobara", "success") self.log("="*80 + "\n", "warning") def install_dependencies(self): """Install dependencies based on distribution""" if self.distro == "pikaos": return self.install_pikaos_dependencies() if self.distro == "pop": return self.install_popos_dependencies() commands = { "arch": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "cachyos": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "endeavouros": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "xerolinux": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "fedora": ["sudo", "dnf", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "p7zip-plugins", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "nobara": ["sudo", "dnf", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "p7zip-plugins", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "opensuse-tumbleweed": ["sudo", "zypper", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"], "opensuse-leap": ["sudo", "zypper", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0"] } if self.distro in commands: self.log(f"Installing dependencies for {self.format_distro_name()}...", "info") self.update_progress_text(f"Installing packages for {self.format_distro_name()}...") self.update_progress(0.6) success, stdout, stderr = self.run_command(commands[self.distro]) if success: self.update_progress(1.0) self.update_progress_text("Dependencies installed") self.log("Dependencies installed successfully", "success") return True else: self.log(f"Failed to install dependencies: {stderr}", "error") # Show retry dialog reply = self.show_question_dialog( "Dependency Installation Failed", f"Failed to install dependencies:\n{stderr}\n\n" "Would you like to retry the installation?", ["Yes", "No"] ) if reply == "Yes": # Retry installation return self.install_dependencies() else: return False self.log(f"Unsupported distribution: {self.format_distro_name()}", "error") return False def install_pikaos_dependencies(self): """Install PikaOS dependencies with WineHQ staging""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("PikaOS Special Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.log("PikaOS's built-in Wine has compatibility issues.", "warning") self.log("Setting up WineHQ staging from Debian...\n", "info") # Total steps: keyrings, gpg key, i386, repo, apt update, wine install, deps install = 7 steps total_steps = 7 current_step = 0 # Create keyrings directory current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Creating keyrings directory...") self.update_progress(current_step / total_steps) self.log("Creating APT keyrings directory...", "info") success, _, _ = self.run_command(["sudo", "mkdir", "-pm755", "/etc/apt/keyrings"]) if not success: self.log("Failed to create keyrings directory", "error") return False # Add GPG key current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding WineHQ GPG key...") self.update_progress(current_step / total_steps) self.log("Adding WineHQ GPG key...", "info") success, stdout, _ = self.run_command(["wget", "-O", "-", "https://dl.winehq.org/wine-builds/winehq.key"]) if success: # Get sudo password for GPG operation password = self.get_sudo_password() if password is None: self.log("Authentication cancelled by user", "error") return False # Validate password if not already validated if not self.sudo_password_validated: if not self.validate_sudo_password(password): self.log("Authentication failed", "error") return False # Run GPG command with sudo gpg_proc = subprocess.Popen( ["sudo", "-S", "gpg", "--dearmor", "-o", "/etc/apt/keyrings/winehq-archive.key", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) # Send password first, then the key data gpg_input = f"{self.sudo_password}\n" + stdout gpg_stdout, gpg_stderr = gpg_proc.communicate(input=gpg_input) if gpg_proc.returncode == 0: self.log("WineHQ GPG key added", "success") else: self.log(f"Failed to add GPG key: {gpg_stderr}", "error") return False else: self.log("Failed to download GPG key", "error") return False # Add i386 architecture current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding i386 architecture...") self.update_progress(current_step / total_steps) self.log("Adding i386 architecture...", "info") success, _, _ = self.run_command(["sudo", "dpkg", "--add-architecture", "i386"]) if not success: self.log("Failed to add i386 architecture", "error") return False # Add repository current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding WineHQ repository...") self.update_progress(current_step / total_steps) self.log("Adding WineHQ repository...", "info") # Remove existing file first to avoid overwrite prompt repo_file = Path("/etc/apt/sources.list.d/winehq-forky.sources") if repo_file.exists(): self.run_command(["sudo", "rm", "-f", str(repo_file)], check=False) success, _, _ = self.run_command([ "sudo", "wget", "-P", "/etc/apt/sources.list.d/", "https://dl.winehq.org/wine-builds/debian/dists/forky/winehq-forky.sources" ]) if not success: self.log("Failed to add repository", "error") return False # Update package lists current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Updating package lists...") self.update_progress(current_step / total_steps) self.log("Updating package lists...", "info") success, _, _ = self.run_command(["sudo", "apt", "update"]) if not success: self.log("Failed to update package lists", "error") return False # Install WineHQ staging current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Installing WineHQ staging...") self.update_progress(current_step / total_steps) self.log("Installing WineHQ staging...", "info") success, _, _ = self.run_command(["sudo", "apt", "install", "--install-recommends", "-y", "winehq-staging"]) if not success: self.log("Failed to install WineHQ staging", "error") return False # Install remaining dependencies current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Installing remaining dependencies...") self.update_progress(current_step / total_steps) self.log("Installing remaining dependencies...", "info") success, _, _ = self.run_command([ "sudo", "apt", "install", "-y", "winetricks", "wget", "curl", "p7zip-full", "tar", "jq", "zstd" ]) if not success: self.log("Failed to install remaining dependencies", "error") return False self.update_progress(1.0) self.update_progress_text("PikaOS dependencies installed") self.log("All dependencies installed for PikaOS", "success") return True def install_popos_dependencies(self): """Install Pop!_OS dependencies with WineHQ staging""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Pop!_OS Special Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.log("Pop!_OS's built-in Wine has compatibility issues.", "warning") self.log("Setting up WineHQ staging from Ubuntu...\n", "info") # Total steps: keyrings, gpg key, i386, repo, apt update, wine install, deps install = 7 steps total_steps = 7 current_step = 0 # Create keyrings directory current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Creating keyrings directory...") self.update_progress(current_step / total_steps) self.log("Creating APT keyrings directory...", "info") success, _, _ = self.run_command(["sudo", "mkdir", "-pm755", "/etc/apt/keyrings"]) if not success: self.log("Failed to create keyrings directory", "error") return False # Add GPG key current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding WineHQ GPG key...") self.update_progress(current_step / total_steps) self.log("Adding WineHQ GPG key...", "info") success, stdout, _ = self.run_command(["wget", "-O", "-", "https://dl.winehq.org/wine-builds/winehq.key"]) if success: # Get sudo password for GPG operation password = self.get_sudo_password() if password is None: self.log("Authentication cancelled by user", "error") return False # Validate password if not already validated if not self.sudo_password_validated: if not self.validate_sudo_password(password): self.log("Authentication failed", "error") return False # Run GPG command with sudo gpg_proc = subprocess.Popen( ["sudo", "-S", "gpg", "--dearmor", "-o", "/etc/apt/keyrings/winehq-archive.key", "-"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True ) # Send password first, then the key data gpg_input = f"{self.sudo_password}\n" + stdout gpg_stdout, gpg_stderr = gpg_proc.communicate(input=gpg_input) if gpg_proc.returncode == 0: self.log("WineHQ GPG key added", "success") else: self.log(f"Failed to add GPG key: {gpg_stderr}", "error") return False else: self.log("Failed to download GPG key", "error") return False # Add i386 architecture current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding i386 architecture...") self.update_progress(current_step / total_steps) self.log("Adding i386 architecture...", "info") success, _, _ = self.run_command(["sudo", "dpkg", "--add-architecture", "i386"]) if not success: self.log("Failed to add i386 architecture", "error") return False # Add repository current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Adding WineHQ repository...") self.update_progress(current_step / total_steps) self.log("Adding WineHQ repository...", "info") # Get Ubuntu version codename codename = "jammy" try: with open("/etc/os-release", "r") as f: for line in f: if line.startswith("VERSION_CODENAME="): codename = line.split("=")[1].strip() except (IOError, FileNotFoundError): pass # Default to jammy # Remove existing file first to avoid overwrite prompt repo_file = Path(f"/etc/apt/sources.list.d/winehq-{codename}.sources") if repo_file.exists(): self.run_command(["sudo", "rm", "-f", str(repo_file)], check=False) success, _, _ = self.run_command([ "sudo", "wget", "-P", "/etc/apt/sources.list.d/", f"https://dl.winehq.org/wine-builds/ubuntu/dists/{codename}/winehq-{codename}.sources" ]) if not success: self.log("Failed to add repository", "error") return False # Update package lists current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Updating package lists...") self.update_progress(current_step / total_steps) self.log("Updating package lists...", "info") success, _, _ = self.run_command(["sudo", "apt", "update"]) if not success: self.log("Failed to update package lists", "error") return False # Install WineHQ staging current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Installing WineHQ staging...") self.update_progress(current_step / total_steps) self.log("Installing WineHQ staging...", "info") success, _, _ = self.run_command(["sudo", "apt", "install", "--install-recommends", "-y", "winehq-staging"]) if not success: self.log("Failed to install WineHQ staging", "error") return False # Install remaining dependencies current_step += 1 self.update_progress_text(f"Step {current_step}/{total_steps}: Installing remaining dependencies...") self.update_progress(current_step / total_steps) self.log("Installing remaining dependencies...", "info") success, _, _ = self.run_command([ "sudo", "apt", "install", "-y", "winetricks", "wget", "curl", "p7zip-full", "tar", "jq", "zstd", "dotnet-sdk-8.0" ]) if not success: self.log("Failed to install remaining dependencies", "error") self.log("Note: dotnet-sdk-8.0 may require Microsoft's repository. You can install it manually if needed.", "warning") return False self.update_progress(1.0) self.update_progress_text("Pop!_OS dependencies installed") self.log("All dependencies installed for Pop!_OS", "success") return True def setup_wine(self): """Setup Wine environment""" self.start_operation("Setting up Wine environment") try: # Check if cancelled at start if self.check_cancelled(): return False self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Wine Binary Setup", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Stop Wine processes self.update_progress_text("Preparing Wine environment...") self.update_progress(0.0) self.log("Stopping Wine processes...", "info") self.run_command(["wineserver", "-k"], check=False) if self.check_cancelled(): return False # Create directory self.update_progress_text("Creating installation directory...") self.update_progress(0.05) Path(self.directory).mkdir(parents=True, exist_ok=True) self.log("Installation directory created", "success") if self.check_cancelled(): return False # Download Wine binary wine_url = "https://github.com/seapear/AffinityOnLinux/releases/download/Legacy/ElementalWarriorWine-x86_64.tar.gz" wine_file = Path(self.directory) / "ElementalWarriorWine-x86_64.tar.gz" self.update_progress_text("Downloading Wine binary...") self.update_progress(0.10) self.log("Downloading Wine binary...", "info") if not self.download_file(wine_url, str(wine_file), "Wine binaries"): self.log("Failed to download Wine binary", "error") self.update_progress_text("Ready") return False if self.check_cancelled(): return False # Extract Wine self.update_progress_text("Extracting Wine binary...") self.update_progress(0.50) self.log("Extracting Wine binary...", "info") try: with tarfile.open(wine_file, "r:gz") as tar: tar.extractall(self.directory, filter='data') wine_file.unlink() self.log("Wine binary extracted", "success") except Exception as e: self.log(f"Failed to extract Wine: {e}", "error") self.update_progress_text("Ready") return False if self.check_cancelled(): return False # Find and link Wine directory self.update_progress(0.55) wine_dir = next(Path(self.directory).glob("ElementalWarriorWine*"), None) if wine_dir and wine_dir != Path(self.directory) / "ElementalWarriorWine": target = Path(self.directory) / "ElementalWarriorWine" if target.exists() or target.is_symlink(): target.unlink() target.symlink_to(wine_dir) self.log("Wine symlink created", "success") # Verify Wine binary self.update_progress(0.60) wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine binary not found", "error") self.update_progress_text("Ready") return False self.log("Wine binary verified", "success") if self.check_cancelled(): return False # Download icons self.update_progress_text("Downloading application icons...") self.update_progress(0.65) self.log("\nSetting up application icons...", "info") icons_dir = Path.home() / ".local" / "share" / "icons" icons_dir.mkdir(parents=True, exist_ok=True) icons = [ ("https://github.com/user-attachments/assets/c7b70ee5-58e3-46c6-b385-7c3d02749664", icons_dir / "AffinityPhoto.svg", "Photo icon"), ("https://github.com/user-attachments/assets/8ea7f748-c455-4ee8-9a94-775de40dbbf3", icons_dir / "AffinityDesigner.svg", "Designer icon"), ("https://github.com/user-attachments/assets/96ae06f8-470b-451f-ba29-835324b5b552", icons_dir / "AffinityPublisher.svg", "Publisher icon"), ("https://raw.githubusercontent.com/seapear/AffinityOnLinux/main/Assets/Icons/Affinity-Canva.svg", icons_dir / "Affinity.svg", "Affinity V3 icon") ] total_icons = len(icons) for idx, (url, path, desc) in enumerate(icons): if self.check_cancelled(): return False icon_progress = 0.65 + (idx / total_icons) * 0.05 self.update_progress(icon_progress) if not self.download_file(url, str(path), desc): self.log(f"Warning: {desc} download failed, but continuing...", "warning") if self.check_cancelled(): return False # Setup WinMetadata self.update_progress_text("Setting up Windows Metadata...") self.update_progress(0.70) self.setup_winmetadata() if self.check_cancelled(): return False # Setup vkd3d-proton (only if OpenCL is enabled) if self.is_opencl_enabled(): self.update_progress_text("Setting up vkd3d-proton for OpenCL...") self.update_progress(0.80) self.setup_vkd3d() else: self.update_progress_text("Skipping OpenCL setup...") self.update_progress(0.80) self.log("OpenCL support is disabled, skipping vkd3d-proton setup", "info") if self.check_cancelled(): return False # Configure Wine self.update_progress_text("Configuring Wine with winetricks...") self.update_progress(0.90) self.configure_wine() if self.check_cancelled(): return False self.setup_complete = True self.update_progress(1.0) self.update_progress_text("Wine setup complete!") self.log("\n✓ Wine setup completed!", "success") # Refresh installation status to update button states QTimer.singleShot(100, self.check_installation_status) return True except Exception as e: if not self.check_cancelled(): self.log(f"Error setting up Wine environment: {e}", "error") return False finally: # Make sure to end the operation even if there was an error or cancellation if hasattr(self, 'current_operation') and self.current_operation == "Setting up Wine environment": self.end_operation() def setup_winmetadata(self): """Download and extract WinMetadata""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Windows Metadata Installation", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") winmetadata_zip = Path(self.directory) / "Winmetadata.zip" system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32" system32_dir.mkdir(parents=True, exist_ok=True) self.update_progress_text("Downloading Windows Metadata...") self.log("Downloading Windows metadata...", "info") if not self.download_file( "https://archive.org/download/win-metadata/WinMetadata.zip", str(winmetadata_zip), "WinMetadata" ): self.log("Failed to download WinMetadata", "warning") return # Extract WinMetadata self.update_progress_text("Extracting Windows Metadata...") self.log("Extracting Windows metadata...", "info") try: if self.check_command("7z"): success, _, _ = self.run_command([ "7z", "x", str(winmetadata_zip), f"-o{system32_dir}", "-y" ]) if success: self.log("WinMetadata extracted using 7z", "success") return elif self.check_command("unzip"): with zipfile.ZipFile(winmetadata_zip, 'r') as zip_ref: zip_ref.extractall(system32_dir) self.log("WinMetadata extracted using unzip", "success") return else: self.log("Neither 7z nor unzip available", "error") except Exception as e: self.log(f"Extraction failed: {e}", "error") def reinstall_winmetadata(self): """Remove old WinMetadata folder and reinstall fresh""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Reinstall WinMetadata", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please setup Wine environment first.", "error") QMessageBox.warning( self, "Wine Not Ready", "Wine setup must complete before reinstalling WinMetadata.\n" "Please setup Wine environment first." ) return self.start_operation("Reinstall WinMetadata") threading.Thread(target=self._reinstall_winmetadata_entry, daemon=True).start() def _reinstall_winmetadata_entry(self): """Wrapper: reinstall WinMetadata and end operation.""" try: self._reinstall_winmetadata_thread() finally: self.end_operation() def _reinstall_winmetadata_thread(self): """Reinstall WinMetadata in background thread""" # Kill Wine processes self.log("Stopping Wine processes...", "info") self.run_command(["wineserver", "-k"], check=False) time.sleep(2) system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32" winmetadata_dir = system32_dir / "WinMetadata" # Remove existing WinMetadata folder if winmetadata_dir.exists(): self.log("Removing existing WinMetadata folder...", "info") try: shutil.rmtree(winmetadata_dir) self.log("Old WinMetadata folder removed", "success") except Exception as e: self.log(f"Warning: Could not fully remove old folder: {e}", "warning") # Also remove the zip file to force re-download winmetadata_zip = Path(self.directory) / "Winmetadata.zip" if winmetadata_zip.exists(): self.log("Removing cached WinMetadata.zip to force fresh download...", "info") try: winmetadata_zip.unlink() self.log("Cached zip file removed", "success") except Exception as e: self.log(f"Warning: Could not remove cached zip: {e}", "warning") # Ensure system32 directory exists system32_dir.mkdir(parents=True, exist_ok=True) # Reinstall WinMetadata self.log("Installing fresh WinMetadata...", "info") self.setup_winmetadata() self.log("\n✓ WinMetadata reinstallation completed!", "success") def setup_vkd3d(self): """Setup vkd3d-proton for OpenCL""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("OpenCL Support Setup", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") vkd3d_url = "https://github.com/HansKristian-Work/vkd3d-proton/releases/download/v2.14.1/vkd3d-proton-2.14.1.tar.zst" vkd3d_file = Path(self.directory) / "vkd3d-proton-2.14.1.tar.zst" vkd3d_temp = Path(self.directory) / "vkd3d_dlls" vkd3d_temp.mkdir(exist_ok=True) self.update_progress_text("Downloading vkd3d-proton...") self.log("Downloading vkd3d-proton...", "info") if not self.download_file(vkd3d_url, str(vkd3d_file), "vkd3d-proton"): self.log("Failed to download vkd3d-proton", "error") return # Extract vkd3d-proton self.update_progress_text("Extracting vkd3d-proton...") self.log("Extracting vkd3d-proton...", "info") if self.check_command("unzstd"): tar_file = Path(self.directory) / "vkd3d-proton.tar" success, _, _ = self.run_command(["unzstd", "-f", str(vkd3d_file), "-o", str(tar_file)]) if success: with tarfile.open(tar_file, "r") as tar: tar.extractall(self.directory, filter='data') tar_file.unlink() self.log("vkd3d-proton extracted", "success") vkd3d_file.unlink() # Copy DLLs vkd3d_dir = next(Path(self.directory).glob("vkd3d-proton-*"), None) if vkd3d_dir: wine_lib_dir = Path(self.directory) / "ElementalWarriorWine" / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows" wine_lib_dir.mkdir(parents=True, exist_ok=True) for dll in ["d3d12.dll", "d3d12core.dll"]: for source_dir in [vkd3d_dir / "x64", vkd3d_dir]: src = source_dir / dll if src.exists(): shutil.copy2(src, vkd3d_temp / dll) shutil.copy2(src, wine_lib_dir / dll) self.log(f"Copied {dll}", "success") break shutil.rmtree(vkd3d_dir) self.log("vkd3d-proton setup completed", "success") def configure_wine(self): """Configure Wine with winetricks""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Wine Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") env = os.environ.copy() env["WINEPREFIX"] = self.directory # Prevent winetricks from showing GUI dialogs env["WINETRICKS_GUI"] = "0" env["DISPLAY"] = env.get("DISPLAY", ":0") # Ensure display is set but winetricks won't use GUI wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" components = [ "dotnet35", "dotnet48", "corefonts", "vcrun2022", "msxml3", "msxml6", "tahoma", "renderer=vulkan" ] self.log("Installing Wine components (this may take several minutes)...", "info") total_components = len(components) for idx, component in enumerate(components): # Calculate base progress for this component (0.0 to 1.0 across all components) base_progress = idx / total_components component_progress_range = 1.0 / total_components # Update progress label to show current component self.update_progress_text(f"Installing: {component} ({idx + 1}/{total_components})") self.log(f"Installing {component}... [{idx + 1}/{total_components}]", "info") self.log(" (Progress will be shown below)", "info") # Progress callback that updates based on component progress def update_component_progress(percent): # percent is 0.0-1.0 for this component # Map it to overall progress overall_progress = base_progress + (percent * component_progress_range) self.update_progress(overall_progress) # Use streaming to show progress self.run_command_streaming( ["winetricks", "--unattended", "--verbose", "--force", "--no-isolate", "--optout", component], env=env, progress_callback=update_component_progress ) # Mark this component as complete self.update_progress(base_progress + component_progress_range) # Set Windows version to 11 self.log("Setting Windows version to 11...", "info") self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) # Apply dark theme self.log("Applying Wine dark theme...", "info") theme_file = Path(self.directory) / "wine-dark-theme.reg" if self.download_file( "https://raw.githubusercontent.com/seapear/AffinityOnLinux/refs/heads/main/Auxiliary/Other/wine-dark-theme.reg", str(theme_file), "dark theme" ): regedit = Path(self.directory) / "ElementalWarriorWine" / "bin" / "regedit" self.run_command([str(regedit), str(theme_file)], check=False, env=env) theme_file.unlink() self.log("Wine configuration completed", "success") self.update_progress_text("Ready") def show_main_menu(self): """Display main application menu""" self.log("\n✓ Setup complete! Select an application to install:", "success") self.update_progress(1.0) def setup_wine_environment(self): """Setup Wine environment only""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Setup Wine Environment", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") threading.Thread(target=self.setup_wine, daemon=True).start() def install_winetricks_deps(self): """Install winetricks dependencies - wrapper for button""" self.install_winetricks_dependencies() def install_system_dependencies(self): """Install system dependencies""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Installing System Dependencies", "info") # Start operation and check for cancellation self.start_operation("Installing System Dependencies") if self.check_cancelled(): return self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") threading.Thread(target=self._install_system_deps, daemon=True).start() def _install_system_deps(self): """Install system dependencies in thread""" if self.distro == "pikaos": self.log("Using PikaOS dependency installation...", "info") success = self.install_pikaos_dependencies() if success: # Also install .NET SDK if not already installed if not self.check_dotnet_sdk(): self.log("Installing .NET SDK...", "info") self.install_dotnet_sdk() self.log("System dependencies installation completed" if success else "System dependencies installation failed", "success" if success else "error") self.end_operation() return if not self.distro: self.detect_distro() self.log(f"Installing dependencies for {self.format_distro_name()}...", "info") success = self.install_dependencies() # After installing main dependencies, check and install .NET SDK if missing # (it should be included in install_dependencies, but check anyway) if success: if not self.check_dotnet_sdk(): self.log(".NET SDK not found in installed packages. Installing separately...", "info") self.install_dotnet_sdk() else: self.log(".NET SDK is already installed", "success") self.log("System dependencies installation completed" if success else "System dependencies installation failed", "success" if success else "error") self.end_operation() return success def install_winetricks_dependencies(self): """Install winetricks dependencies""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Installing Winetricks Dependencies", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Start operation and check for cancellation self.start_operation("Installing Winetricks Dependencies") if self.check_cancelled(): return # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please wait for Wine setup to complete.", "error") QMessageBox.warning(self, "Wine Not Ready", "Wine setup must complete before installing winetricks dependencies.") self.end_operation() return threading.Thread(target=self._install_winetricks_deps, daemon=True).start() def _install_winetricks_deps(self): """Install winetricks dependencies in thread""" try: if self.check_cancelled(): return env = os.environ.copy() env["WINEPREFIX"] = self.directory # Prevent winetricks from showing GUI dialogs env["WINETRICKS_GUI"] = "0" env["DISPLAY"] = env.get("DISPLAY", ":0") # Ensure display is set but winetricks won't use GUI components = [ ("dotnet35", ".NET Framework 3.5"), ("dotnet48", ".NET Framework 4.8"), ("corefonts", "Windows Core Fonts"), ("vcrun2022", "Visual C++ Redistributables 2022"), ("msxml3", "MSXML 3.0"), ("msxml6", "MSXML 6.0"), ("tahoma", "Tahoma Font"), ("renderer=vulkan", "Vulkan Renderer") ] except Exception as e: self.log(f"Error in winetricks dependencies installation: {str(e)}", "error") self.log("Please check the logs and try again.", "error") self.end_operation() return self.log("Installing Wine components (this may take several minutes)...", "info") total_components = len(components) for idx, (component, description) in enumerate(components): # Calculate base progress for this component (0.0 to 1.0 across all components) base_progress = idx / total_components component_progress_range = 1.0 / total_components # Update progress label to show current component self.update_progress_text(f"Installing: {description} ({idx + 1}/{total_components})") self.log(f"Installing {description} ({component})... [{idx + 1}/{total_components}]", "info") self.log(" (This may take several minutes - progress will be shown below)", "info") # Progress callback that updates based on component progress def update_component_progress(percent): # Update progress label to show current component self.update_progress_text(f"Installing: {description} ({idx + 1}/{total_components})") self.log(f"Installing {description} ({component})... [{idx + 1}/{total_components}]", "info") self.log(" (This may take several minutes - progress will be shown below)", "info") # Progress callback that updates based on component progress def update_component_progress(percent): # percent is 0.0-1.0 for this component # Map it to overall progress overall_progress = base_progress + (percent * component_progress_range) self.update_progress(overall_progress) # Check for cancellation before starting installation if self.check_cancelled(): return # Use streaming to show progress in real-time # Keep --unattended to prevent dialogs, but remove it for verbose output # We'll use verbose mode to see progress try: success = self.run_command_streaming( ["winetricks", "--unattended", "--verbose", "--force", "--no-isolate", "--optout", component], env=env, progress_callback=update_component_progress ) if success and not self.check_cancelled(): self.log(f"✓ {description} installed", "success") elif not success and not self.check_cancelled(): # If installation failed, try once more with force self.log(f"⚠ {description} installation failed, retrying...", "warning") time.sleep(2) # Brief pause before retry self.log(f"Retrying {description} installation...", "info") retry_success = self.run_command_streaming( ["winetricks", "--unattended", "--verbose", "--force", "--no-isolate", "--optout", component], env=env, progress_callback=update_component_progress ) # Mark component as complete after retry self.update_progress(base_progress + component_progress_range) if retry_success: self.log(f"✓ {description} installed successfully on retry", "success") else: # Check if it might already be installed by checking the component if self._check_winetricks_component(component.split('=')[0] if '=' in component else component, Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine", env): self.log(f"✓ {description} appears to already be installed", "success") else: self.log(f"✗ {description} installation failed after retry. You may need to install manually.", "error") except Exception as e: if not self.check_cancelled(): self.log(f"Error during Winetricks installation: {e}", "error") finally: # Make sure to end the operation even if there was an error or cancellation if hasattr(self, 'current_operation') and self.current_operation == "Installing Winetricks Dependencies": self.end_operation() # Windows 11 compatibility will be set below # Set Windows version to 11 wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" self.log("Setting Windows version to 11...", "info") self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) # Apply dark theme self.log("Applying Wine dark theme...", "info") theme_file = Path(self.directory) / "wine-dark-theme.reg" if self.download_file( "https://raw.githubusercontent.com/seapear/AffinityOnLinux/refs/heads/main/Auxiliary/Other/wine-dark-theme.reg", str(theme_file), "dark theme" ): regedit = Path(self.directory) / "ElementalWarriorWine" / "bin" / "regedit" self.run_command([str(regedit), str(theme_file)], check=False, env=env) theme_file.unlink() self.log("Dark theme applied", "success") self.log("\n✓ Winetricks dependencies installation completed!", "success") self.update_progress_text("Ready") self.end_operation() def install_affinity_settings(self): """Install Affinity v3 (Unified) settings files to enable settings saving""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Fix Settings (Affinity v3 only)", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self.log("Note: This fix applies only to Affinity v3 (Unified).", "info") # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please setup Wine environment first.", "error") QMessageBox.warning( self, "Wine Not Ready", "Wine setup must complete before installing Affinity v3 settings.\n" "Please setup Wine environment first." ) return # Start operation and wrapper thread self.start_operation("Install Affinity v3 Settings") threading.Thread(target=self._install_affinity_settings_entry, daemon=True).start() def _install_affinity_settings_thread(self): """Install Affinity v3 (Unified) settings in background thread - downloads repo and copies Settings""" # Determine Windows username # Wine typically uses "Public" as the default username, but check for existing users users_dir = Path(self.directory) / "drive_c" / "users" username = "Public" # Default Wine username # Check if users directory exists and has other users if users_dir.exists(): # Look for existing user directories (excluding Public, Default, etc.) existing_users = [d.name for d in users_dir.iterdir() if d.is_dir() and d.name not in ["Public", "Default", "All Users", "Default User"]] if existing_users: # Use the first existing user, or fall back to Public username = existing_users[0] self.log(f"Using existing Windows user: {username}", "info") else: self.log(f"Using default Windows user: {username}", "info") else: self.log(f"Creating users directory structure for: {username}", "info") users_dir.mkdir(parents=True, exist_ok=True) # Create temp directory for cloning/downloading temp_dir = Path(self.directory) / ".temp_settings" if temp_dir.exists(): self.log("Cleaning up existing temp directory...", "info") try: shutil.rmtree(temp_dir) except Exception as e: self.log(f"Warning: Could not remove existing temp dir: {e}", "warning") temp_dir.mkdir(exist_ok=True) # Download the repository as a zip file self.update_progress_text("Downloading Settings from repository...") self.update_progress(0.1) self.log("Downloading Settings from GitHub repository...", "info") repo_zip = temp_dir / "AffinityOnLinux.zip" repo_url = "https://github.com/seapear/AffinityOnLinux/archive/refs/heads/main.zip" if not self.download_file(repo_url, str(repo_zip), "Settings repository"): self.log("Failed to download Settings repository", "error") self.log(f" URL: {repo_url}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # Verify the zip file was downloaded if not repo_zip.exists() or repo_zip.stat().st_size == 0: self.log("Downloaded zip file is missing or empty", "error") try: shutil.rmtree(temp_dir) except Exception: pass return self.log(f"Downloaded zip file size: {repo_zip.stat().st_size / 1024 / 1024:.2f} MB", "info") # Extract the zip file self.update_progress_text("Extracting Settings repository...") self.update_progress(0.3) self.log("Extracting Settings repository...", "info") try: if self.check_command("7z"): success, stdout, stderr = self.run_command([ "7z", "x", str(repo_zip), f"-o{temp_dir}", "-y" ]) if not success: self.log(f"7z extraction failed: {stderr}", "error") raise Exception("7z extraction failed") self.log("Extraction completed with 7z", "success") elif self.check_command("unzip"): with zipfile.ZipFile(repo_zip, 'r') as zip_ref: zip_ref.extractall(temp_dir) self.log("Extraction completed with unzip", "success") else: self.log("Neither 7z nor unzip available for extraction", "error") self.log("Please install 7z or unzip to extract the repository", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # Find the extracted directory (usually AffinityOnLinux-main) extracted_dirs = list(temp_dir.glob("AffinityOnLinux-*")) self.log(f"Found {len(extracted_dirs)} extracted director{'y' if len(extracted_dirs) == 1 else 'ies'}", "info") extracted_dir = extracted_dirs[0] if extracted_dirs else None if not extracted_dir: self.log("Could not find extracted repository directory", "error") self.log(f"Contents of temp_dir: {[d.name for d in temp_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return self.log(f"Using extracted directory: {extracted_dir.name}", "info") # Check if Auxiliary directory exists auxiliary_dir = extracted_dir / "Auxiliary" if not auxiliary_dir.exists(): self.log("Auxiliary directory not found in repository", "error") self.log(f"Contents of extracted directory: {[d.name for d in extracted_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return settings_dir = auxiliary_dir / "Settings" if not settings_dir.exists(): self.log("Settings directory not found in Auxiliary", "error") self.log(f"Contents of Auxiliary: {[d.name for d in auxiliary_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # List what's in the Settings directory settings_contents = [d.name for d in settings_dir.iterdir() if d.is_dir()] self.log(f"Found Settings folders: {settings_contents}", "info") # Source Settings directory path - For Affinity v3 (Unified), use 3.0 # $APP would be "Affinity" and version is 3.0 # So the source should be: Auxiliary/Settings/Affinity/3.0/Settings self.update_progress_text("Locating Settings files...") self.update_progress(0.5) settings_source_dirs = [ settings_dir / "Affinity" / "3.0" / "Settings", # Affinity v3 uses 3.0 settings_dir / "Affinity" / "Settings", settings_dir / "Unified" / "3.0" / "Settings", settings_dir / "Unified" / "Settings", ] settings_source = None for source_dir in settings_source_dirs: if source_dir.exists(): files = list(source_dir.iterdir()) if files: settings_source = source_dir self.log(f"Found settings at: {source_dir.relative_to(extracted_dir)}", "success") self.log(f" Contains {len(files)} file(s)/folder(s)", "info") break if not settings_source: self.log("Settings directory not found in repository", "error") self.log("Tried paths:", "error") for path in settings_source_dirs: self.log(f" - {path.relative_to(extracted_dir)}: {'exists' if path.exists() else 'not found'}", "error") # List what's actually in Settings/Affinity if it exists affinity_settings = settings_dir / "Affinity" if affinity_settings.exists(): self.log(f"Contents of Settings/Affinity: {[d.name for d in affinity_settings.iterdir()]}", "info") try: shutil.rmtree(temp_dir) except Exception: pass return # Target directory in Wine prefix # Based on Settings.md: mv $APP/3.0/Settings drive_c/users/$USERNAME/AppData/Roaming/Affinity/ # For Affinity v3, this means: Affinity/3.0/Settings -> AppData/Roaming/Affinity/Affinity/3.0/Settings affinity_appdata = users_dir / username / "AppData" / "Roaming" / "Affinity" # Check what version folder Affinity v3 actually uses by looking at existing structure affinity_dir = affinity_appdata / "Affinity" version_folder = None if affinity_dir.exists(): existing_versions = [d.name for d in affinity_dir.iterdir() if d.is_dir()] if existing_versions: # Prefer 3.0 for Affinity v3 if "3.0" in existing_versions: version_folder = "3.0" elif "2.0" in existing_versions: version_folder = "2.0" else: # Use the first one found (sorted) version_folder = sorted(existing_versions)[0] self.log(f"Found existing Affinity version folder: {version_folder}", "info") # If no existing version folder, use 3.0 for Affinity v3 if not version_folder: # Try to detect from source path source_parts = settings_source.parts if "3.0" in source_parts: version_folder = "3.0" elif "2.0" in source_parts: version_folder = "2.0" else: version_folder = "3.0" # Default to 3.0 for Affinity v3 self.log(f"Using version folder: {version_folder} (Affinity v3 uses 3.0)", "info") # Target path: AppData/Roaming/Affinity/Affinity/3.0/Settings (for v3) target_dir = affinity_appdata / "Affinity" / version_folder / "Settings" # Remove existing settings if they exist (to force fresh copy) if target_dir.exists(): self.log(f"Removing existing settings from: {target_dir}", "info") try: shutil.rmtree(target_dir) self.log("Old settings removed", "success") except Exception as e: self.log(f"Warning: Could not fully remove old settings: {e}", "warning") # Copy settings from source to target self.update_progress_text("Copying Settings files...") self.update_progress(0.7) target_dir.parent.mkdir(parents=True, exist_ok=True) self.log(f"Copying settings from repository to Wine prefix...", "info") self.log(f" From: {settings_source}", "info") self.log(f" To: {target_dir}", "info") # Copy with metadata preservation shutil.copytree(settings_source, target_dir, dirs_exist_ok=True) self.update_progress(0.9) self.log(f"Settings copied successfully to: {target_dir}", "success") # Verify the copy copied_files = list(target_dir.rglob("*")) source_files = list(settings_source.rglob("*")) self.log(f"Copied {len(copied_files)} file(s)/folder(s) (source had {len(source_files)})", "success") # List some of the copied files for verification xml_files = list(target_dir.rglob("*.xml")) if xml_files: self.log(f"Found {len(xml_files)} XML file(s) in settings", "info") for xml_file in xml_files[:5]: # Show first 5 self.log(f" - {xml_file.relative_to(target_dir)}", "info") # Set permissions (make sure files are readable) try: import os for root, dirs, files in os.walk(target_dir): for d in dirs: os.chmod(os.path.join(root, d), 0o755) for f in files: os.chmod(os.path.join(root, f), 0o644) self.log("File permissions set correctly", "success") except Exception as e: self.log(f"Note: Could not set permissions: {e}", "warning") # Clean up temp files try: shutil.rmtree(temp_dir) self.log("Temp files cleaned up", "info") except Exception as e: self.log(f"Note: Could not clean up temp files: {e}", "warning") self.update_progress(1.0) self.update_progress_text("Settings installation complete!") self.log("\n✓ Affinity v3 settings installation completed!", "success") self.log("Settings files have been installed for Affinity v3 (Unified).", "info") except Exception as e: import traceback self.log(f"Error installing settings: {e}", "error") self.log(f"Traceback: {traceback.format_exc()}", "error") # Clean up on error try: shutil.rmtree(temp_dir) repo_zip.unlink(missing_ok=True) except: pass def _check_winetricks_component(self, component, wine, env): """Check if a winetricks component is installed""" try: # Different checks for different components if component == "dotnet35": # Check for .NET 3.5 in registry success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_LOCAL_MACHINE\\Software\\Microsoft\\NET Framework Setup\\NDP\\v3.5", "/v", "Install"], check=False, env=env, capture=True ) if success and stdout: # Check if Install value is 1 if "0x1" in stdout or "REG_DWORD" in stdout: return True elif component == "dotnet48": # Check for .NET 4.8 in registry success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_LOCAL_MACHINE\\Software\\Microsoft\\NET Framework Setup\\NDP\\v4\\Full", "/v", "Release"], check=False, env=env, capture=True ) if success and stdout: # .NET 4.8 has release number 528040 or higher match = re.search(r'0x([0-9a-fA-F]+)', stdout) if match: release = int(match.group(1), 16) if release >= 528040: # .NET 4.8 return True elif component == "corefonts": # Check if core fonts directory exists fonts_dir = Path(self.directory) / "drive_c" / "windows" / "Fonts" if fonts_dir.exists(): # Check for some common core fonts core_fonts = ["arial.ttf", "times.ttf", "courier.ttf", "tahoma.ttf"] for font in core_fonts: if (fonts_dir / font).exists(): return True elif component == "vcrun2022": # Check for Visual C++ 2022 redistributables vcrun_paths = [ Path(self.directory) / "drive_c" / "windows" / "system32" / "vcruntime140.dll", Path(self.directory) / "drive_c" / "windows" / "syswow64" / "vcruntime140.dll", ] for vcrun_path in vcrun_paths: if vcrun_path.exists(): return True elif component == "msxml3": # Check for MSXML3 msxml3_path = Path(self.directory) / "drive_c" / "windows" / "system32" / "msxml3.dll" if msxml3_path.exists(): return True elif component == "msxml6": # Check for MSXML6 msxml6_path = Path(self.directory) / "drive_c" / "windows" / "system32" / "msxml6.dll" if msxml6_path.exists(): return True except Exception: pass return False def check_webview2_installed(self): """Check if WebView2 Runtime is already installed""" wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine.exists(): return False env = os.environ.copy() env["WINEPREFIX"] = self.directory # Check for WebView2 installation directory webview2_paths = [ Path(self.directory) / "drive_c" / "Program Files (x86)" / "Microsoft" / "EdgeWebView" / "Application", Path(self.directory) / "drive_c" / "Program Files" / "Microsoft" / "EdgeWebView" / "Application", ] for webview2_path in webview2_paths: if webview2_path.exists(): # Check if msedgewebview2.exe exists msedgewebview2_exe = webview2_path / "msedgewebview2.exe" if msedgewebview2_exe.exists(): return True # Also check registry for WebView2 installation try: success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"], check=False, env=env, capture=True ) if success: return True except Exception: pass return False def install_webview2_runtime(self): """Install Microsoft Edge WebView2 Runtime for Affinity v3 (Unified)""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Installing Microsoft Edge WebView2 Runtime (Affinity v3)", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please setup Wine environment first.", "error") QMessageBox.warning( self, "Wine Not Ready", "Wine setup must complete before installing WebView2 Runtime.\n" "Please setup Wine environment first." ) return # Start operation and wrapper thread self.start_operation("Install WebView2 Runtime") threading.Thread(target=self._install_webview2_runtime_entry, daemon=True).start() def _install_webview2_runtime_entry(self): """Wrapper to install WebView2 and end the operation when invoked from the button.""" try: self._install_webview2_runtime_thread() finally: self.end_operation() def _install_webview2_runtime_thread(self): """Install Microsoft Edge WebView2 Runtime in background thread""" # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please setup Wine environment first.", "error") return False env = os.environ.copy() env["WINEPREFIX"] = self.directory wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" regedit = Path(self.directory) / "ElementalWarriorWine" / "bin" / "regedit" wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" # Check if WebView2 Runtime is already installed self.log("Checking if WebView2 Runtime is already installed...", "info") webview2_installed = False # Check for WebView2 installation directory webview2_paths = [ Path(self.directory) / "drive_c" / "Program Files (x86)" / "Microsoft" / "EdgeWebView" / "Application", Path(self.directory) / "drive_c" / "Program Files" / "Microsoft" / "EdgeWebView" / "Application", ] for webview2_path in webview2_paths: if webview2_path.exists(): # Check if msedgewebview2.exe exists msedgewebview2_exe = webview2_path / "msedgewebview2.exe" if msedgewebview2_exe.exists(): webview2_installed = True self.log(f"WebView2 Runtime found at: {webview2_path}", "success") break # Also check registry for WebView2 installation if not webview2_installed: try: success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_LOCAL_MACHINE\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"], check=False, env=env, capture=True ) if success: webview2_installed = True self.log("WebView2 Runtime found in registry", "success") except Exception: pass if webview2_installed: self.log("WebView2 Runtime is already installed. Skipping installation.", "info") self.log("Verifying configuration...", "info") # Still configure the compatibility settings even if already installed # Step 1: Disable Microsoft Edge Update services (if not already done) self.log("Ensuring Edge Update services are disabled...", "info") disable_edge_update_reg = Path(self.directory) / "disable-edge-update.reg" with open(disable_edge_update_reg, "w") as f: f.write("Windows Registry Editor Version 5.00\n\n") f.write("[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Services\\edgeupdate]\n") f.write("\"Start\"=dword:00000004\n\n") f.write("[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Services\\edgeupdatem]\n") f.write("\"Start\"=dword:00000004\n") self.run_command([str(regedit), str(disable_edge_update_reg)], check=False, env=env) disable_edge_update_reg.unlink() # Step 2: Set msedgewebview2.exe to Windows 7 compatibility (if not already set) self.log("Ensuring msedgewebview2.exe Windows 7 compatibility is set...", "info") webview2_win7_reg = Path(self.directory) / "webview2-win7-cap.reg" with open(webview2_win7_reg, "w") as f: f.write("Windows Registry Editor Version 5.00\n\n") f.write("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults]\n\n") f.write("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\msedgewebview2.exe]\n") f.write("\"Version\"=\"win7\"\n") self.run_command([str(regedit), str(webview2_win7_reg)], check=False, env=env) webview2_win7_reg.unlink() self.log("\n✓ WebView2 Runtime configuration verified!", "success") self.log("WebView2 Runtime is installed and configured correctly.", "info") return True # WebView2 not found, proceed with installation self.log("WebView2 Runtime not found. Proceeding with installation...", "info") try: # Step 1: Set Windows 7 compatibility mode self.log("Setting Windows 7 compatibility mode...", "info") self.run_command([str(wine_cfg), "-v", "win7"], check=False, env=env) self.log("Windows 7 compatibility mode set", "success") # Step 2: Download Microsoft Edge WebView2 Runtime self.log("Downloading Microsoft Edge WebView2 Runtime (v109.0.1518.78)...", "info") webview2_url = "https://archive.org/download/microsoft-edge-web-view-2-runtime-installer-v109.0.1518.78/MicrosoftEdgeWebView2RuntimeInstallerX64.exe" webview2_file = Path(self.directory) / "MicrosoftEdgeWebView2RuntimeInstallerX64.exe" if not self.download_file(webview2_url, str(webview2_file), "WebView2 Runtime"): self.log("Failed to download WebView2 Runtime", "error") return False self.log("WebView2 Runtime downloaded", "success") # Step 3: Install WebView2 Runtime self.log("Installing Microsoft Edge WebView2 Runtime...", "info") self.log("This may take a few minutes...", "info") env["WINEDEBUG"] = "-all" self.run_command([str(wine), str(webview2_file)], check=False, env=env, capture=False) # Wait for installation to complete time.sleep(5) self.log("WebView2 Runtime installation completed", "success") # Step 4: Disable Microsoft Edge Update services self.log("Disabling Microsoft Edge Update services...", "info") disable_edge_update_reg = Path(self.directory) / "disable-edge-update.reg" with open(disable_edge_update_reg, "w") as f: f.write("Windows Registry Editor Version 5.00\n\n") f.write("[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Services\\edgeupdate]\n") f.write("\"Start\"=dword:00000004\n\n") f.write("[HKEY_LOCAL_MACHINE\\System\\CurrentControlSet\\Services\\edgeupdatem]\n") f.write("\"Start\"=dword:00000004\n") self.run_command([str(regedit), str(disable_edge_update_reg)], check=False, env=env) disable_edge_update_reg.unlink() self.log("Edge Update services disabled", "success") # Step 5: Set back to Windows 11 compatibility self.log("Setting Windows 11 compatibility mode...", "info") self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) self.log("Windows 11 compatibility mode set", "success") # Step 6: Set msedgewebview2.exe to Windows 7 compatibility self.log("Setting msedgewebview2.exe to Windows 7 compatibility...", "info") webview2_win7_reg = Path(self.directory) / "webview2-win7-cap.reg" with open(webview2_win7_reg, "w") as f: f.write("Windows Registry Editor Version 5.00\n\n") f.write("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults]\n\n") f.write("[HKEY_CURRENT_USER\\Software\\Wine\\AppDefaults\\msedgewebview2.exe]\n") f.write("\"Version\"=\"win7\"\n") self.run_command([str(regedit), str(webview2_win7_reg)], check=False, env=env) webview2_win7_reg.unlink() self.log("msedgewebview2.exe Windows 7 compatibility set", "success") # Clean up installer file if webview2_file.exists(): webview2_file.unlink() self.log("WebView2 installer file removed", "success") self.log("\n✓ Microsoft Edge WebView2 Runtime installation completed!", "success") self.log("WebView2 Runtime v109.0.1518.78 has been installed for Affinity v3.", "info") self.log("Help > View Help should now work in Affinity v3.", "info") return True except Exception as e: if not self.check_cancelled(): self.log(f"Error installing WebView2 Runtime: {e}", "error") # Try to restore Windows 11 compatibility even if something failed try: self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) except: pass return False def _install_affinity_settings_entry(self): """Wrapper to install Affinity settings and end the operation when invoked from the button.""" try: self._install_affinity_settings_thread() finally: self.end_operation() def _install_affinity_settings_thread(self): """Install Affinity v3 (Unified) settings in background thread - downloads repo and copies Settings""" try: # Determine Windows username # Wine typically uses "Public" as the default username, but check for existing users users_dir = Path(self.directory) / "drive_c" / "users" username = "Public" # Default Wine username # Check if users directory exists and has other users if users_dir.exists(): # Look for existing user directories (excluding Public, Default, etc.) existing_users = [d.name for d in users_dir.iterdir() if d.is_dir() and d.name not in ["Public", "Default", "All Users", "Default User"]] if existing_users: # Use the first existing user, or fall back to Public username = existing_users[0] self.log(f"Using existing Windows user: {username}", "info") else: self.log(f"Using default Windows user: {username}", "info") else: self.log(f"Creating users directory structure for: {username}", "info") users_dir.mkdir(parents=True, exist_ok=True) # Create temp directory for cloning/downloading temp_dir = Path(self.directory) / ".temp_settings" if temp_dir.exists(): self.log("Cleaning up existing temp directory...", "info") try: shutil.rmtree(temp_dir) except Exception as e: self.log(f"Warning: Could not remove existing temp dir: {e}", "warning") temp_dir.mkdir(exist_ok=True) # Download the repository as a zip file self.update_progress_text("Downloading Settings from repository...") self.update_progress(0.1) self.log("Downloading Settings from GitHub repository...", "info") repo_zip = temp_dir / "AffinityOnLinux.zip" repo_url = "https://github.com/seapear/AffinityOnLinux/archive/refs/heads/main.zip" if not self.download_file(repo_url, str(repo_zip), "Settings repository"): self.log("Failed to download Settings repository", "error") self.log(f" URL: {repo_url}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # Verify the zip file was downloaded if not repo_zip.exists() or repo_zip.stat().st_size == 0: self.log("Downloaded zip file is missing or empty", "error") try: shutil.rmtree(temp_dir) except Exception: pass return self.log(f"Downloaded zip file size: {repo_zip.stat().st_size / 1024 / 1024:.2f} MB", "info") # Extract the zip file self.update_progress_text("Extracting Settings repository...") self.update_progress(0.3) self.log("Extracting Settings repository...", "info") try: if self.check_command("7z"): success, stdout, stderr = self.run_command([ "7z", "x", str(repo_zip), f"-o{temp_dir}", "-y" ]) if not success: self.log(f"7z extraction failed: {stderr}", "error") raise Exception("7z extraction failed") self.log("Extraction completed with 7z", "success") elif self.check_command("unzip"): with zipfile.ZipFile(repo_zip, 'r') as zip_ref: zip_ref.extractall(temp_dir) self.log("Extraction completed with unzip", "success") else: self.log("Neither 7z nor unzip available for extraction", "error") self.log("Please install 7z or unzip to extract the repository", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # Find the extracted directory (usually AffinityOnLinux-main) extracted_dirs = list(temp_dir.glob("AffinityOnLinux-*")) self.log(f"Found {len(extracted_dirs)} extracted director{'y' if len(extracted_dirs) == 1 else 'ies'}", "info") extracted_dir = extracted_dirs[0] if extracted_dirs else None if not extracted_dir: self.log("Could not find extracted repository directory", "error") self.log(f"Contents of temp_dir: {[d.name for d in temp_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return self.log(f"Using extracted directory: {extracted_dir.name}", "info") # Check if Auxiliary directory exists auxiliary_dir = extracted_dir / "Auxiliary" if not auxiliary_dir.exists(): self.log("Auxiliary directory not found in repository", "error") self.log(f"Contents of extracted directory: {[d.name for d in extracted_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return settings_dir = auxiliary_dir / "Settings" if not settings_dir.exists(): self.log("Settings directory not found in Auxiliary", "error") self.log(f"Contents of Auxiliary: {[d.name for d in auxiliary_dir.iterdir()]}", "error") try: shutil.rmtree(temp_dir) except Exception: pass return # List what's in the Settings directory settings_contents = [d.name for d in settings_dir.iterdir() if d.is_dir()] self.log(f"Found Settings folders: {settings_contents}", "info") # Source Settings directory path - For Affinity v3 (Unified), use 3.0 # $APP would be "Affinity" and version is 3.0 # So the source should be: Auxiliary/Settings/Affinity/3.0/Settings self.update_progress_text("Locating Settings files...") self.update_progress(0.5) settings_source_dirs = [ settings_dir / "Affinity" / "3.0" / "Settings", # Affinity v3 uses 3.0 settings_dir / "Affinity" / "Settings", settings_dir / "Unified" / "3.0" / "Settings", settings_dir / "Unified" / "Settings", ] settings_source = None for source_dir in settings_source_dirs: if source_dir.exists(): files = list(source_dir.iterdir()) if files: settings_source = source_dir self.log(f"Found settings at: {source_dir.relative_to(extracted_dir)}", "success") self.log(f" Contains {len(files)} file(s)/folder(s)", "info") break if not settings_source: self.log("Settings directory not found in repository", "error") self.log("Tried paths:", "error") for path in settings_source_dirs: self.log(f" - {path.relative_to(extracted_dir)}: {'exists' if path.exists() else 'not found'}", "error") # List what's actually in Settings/Affinity if it exists affinity_settings = settings_dir / "Affinity" if affinity_settings.exists(): self.log(f"Contents of Settings/Affinity: {[d.name for d in affinity_settings.iterdir()]}", "info") try: shutil.rmtree(temp_dir) except Exception: pass return # Target directory in Wine prefix # Based on Settings.md: mv $APP/3.0/Settings drive_c/users/$USERNAME/AppData/Roaming/Affinity/ # For Affinity v3, this means: Affinity/3.0/Settings -> AppData/Roaming/Affinity/Affinity/3.0/Settings affinity_appdata = users_dir / username / "AppData" / "Roaming" / "Affinity" # Check what version folder Affinity v3 actually uses by looking at existing structure affinity_dir = affinity_appdata / "Affinity" version_folder = None if affinity_dir.exists(): existing_versions = [d.name for d in affinity_dir.iterdir() if d.is_dir()] if existing_versions: # Prefer 3.0 for Affinity v3 if "3.0" in existing_versions: version_folder = "3.0" elif "2.0" in existing_versions: version_folder = "2.0" else: # Use the first one found (sorted) version_folder = sorted(existing_versions)[0] self.log(f"Found existing Affinity version folder: {version_folder}", "info") # If no existing version folder, use 3.0 for Affinity v3 if not version_folder: # Try to detect from source path source_parts = settings_source.parts if "3.0" in source_parts: version_folder = "3.0" elif "2.0" in source_parts: version_folder = "2.0" else: version_folder = "3.0" # Default to 3.0 for Affinity v3 self.log(f"Using version folder: {version_folder} (Affinity v3 uses 3.0)", "info") # Target path: AppData/Roaming/Affinity/Affinity/3.0/Settings (for v3) target_dir = affinity_appdata / "Affinity" / version_folder / "Settings" # Remove existing settings if they exist (to force fresh copy) if target_dir.exists(): self.log(f"Removing existing settings from: {target_dir}", "info") try: shutil.rmtree(target_dir) self.log("Old settings removed", "success") except Exception as e: self.log(f"Warning: Could not fully remove old settings: {e}", "warning") # Copy settings from source to target self.update_progress_text("Copying Settings files...") self.update_progress(0.7) target_dir.parent.mkdir(parents=True, exist_ok=True) self.log(f"Copying settings from repository to Wine prefix...", "info") self.log(f" From: {settings_source}", "info") self.log(f" To: {target_dir}", "info") # Copy with metadata preservation shutil.copytree(settings_source, target_dir, dirs_exist_ok=True) self.update_progress(0.9) self.log(f"Settings copied successfully to: {target_dir}", "success") # Verify the copy copied_files = list(target_dir.rglob("*")) source_files = list(settings_source.rglob("*")) self.log(f"Copied {len(copied_files)} file(s)/folder(s) (source had {len(source_files)})", "success") # List some of the copied files for verification xml_files = list(target_dir.rglob("*.xml")) if xml_files: self.log(f"Found {len(xml_files)} XML file(s) in settings", "info") for xml_file in xml_files[:5]: # Show first 5 self.log(f" - {xml_file.relative_to(target_dir)}", "info") # Set permissions (make sure files are readable) try: import os for root, dirs, files in os.walk(target_dir): for d in dirs: os.chmod(os.path.join(root, d), 0o755) for f in files: os.chmod(os.path.join(root, f), 0o644) self.log("File permissions set correctly", "success") except Exception as e: self.log(f"Note: Could not set permissions: {e}", "warning") # Clean up temp files try: shutil.rmtree(temp_dir) self.log("Temp files cleaned up", "info") except Exception as e: self.log(f"Note: Could not clean up temp files: {e}", "warning") self.update_progress(1.0) self.update_progress_text("Settings installation complete!") self.log("\n✓ Affinity v3 settings installation completed!", "success") self.log("Settings files have been installed for Affinity v3 (Unified).", "info") except Exception as e: import traceback self.log(f"Error installing settings: {e}", "error") self.log(f"Traceback: {traceback.format_exc()}", "error") try: shutil.rmtree(temp_dir) repo_zip.unlink(missing_ok=True) except Exception: pass except Exception as e: import traceback self.log(f"Error installing settings: {e}", "error") self.log(f"Traceback: {traceback.format_exc()}", "error") # Clean up on error try: shutil.rmtree(temp_dir) repo_zip.unlink(missing_ok=True) except: pass def install_from_file(self): """Install from file manager - custom .exe file""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Custom Installer from File Manager", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if Wine is set up wine_binary = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine_binary.exists(): self.log("Wine is not set up yet. Please wait for Wine setup to complete.", "error") QMessageBox.warning( self, "Wine Not Ready", "Wine setup must complete before installing applications.\n" "Please wait for the initialization to finish." ) return # Open file dialog to select .exe self.log("Please select the installer .exe file...", "info") installer_path, _ = QFileDialog.getOpenFileName( self, "Select Installer (.exe)", "", "Executable files (*.exe);;All files (*.*)" ) if not installer_path: self.log("Installation cancelled.", "warning") return # Detect app name from filename - check multiple patterns filename_lower = Path(installer_path).name.lower() filename_no_spaces = filename_lower.replace(" ", "").replace("-", "").replace("_", "") app_name = None # Check various patterns that might be in Affinity installer filenames if "photo" in filename_lower or "photo" in filename_no_spaces: app_name = "Photo" self.log(f"Detected: Affinity Photo (from filename: {Path(installer_path).name})", "info") elif "designer" in filename_lower or "designer" in filename_no_spaces: app_name = "Designer" self.log(f"Detected: Affinity Designer (from filename: {Path(installer_path).name})", "info") elif "publisher" in filename_lower or "publisher" in filename_no_spaces: app_name = "Publisher" self.log(f"Detected: Affinity Publisher (from filename: {Path(installer_path).name})", "info") else: self.log(f"Could not detect Affinity app from filename: {Path(installer_path).name}", "warning") self.log("Desktop entry will not be created automatically for non-Affinity apps.", "info") if app_name: self.log(f"Will automatically create desktop entry for {app_name}", "info") # Start operation and installation self.start_operation("Custom Installation") threading.Thread( target=self._run_custom_installation_entry, args=(installer_path, app_name), daemon=True ).start() def _run_custom_installation_entry(self, installer_path, app_name): """Wrapper: run custom installation and always end operation.""" try: self.run_custom_installation(installer_path, app_name) finally: self.end_operation() def run_custom_installation(self, installer_path, app_name): """Run custom installation process""" try: self.log(f"Selected installer: {installer_path}", "success") # Copy installer with sanitized filename (remove spaces) original_filename = Path(installer_path).name sanitized_filename = self.sanitize_filename(original_filename) installer_file = Path(self.directory) / sanitized_filename shutil.copy2(installer_path, installer_file) self.log(f"Installer {original_filename} copied to Wine prefix: {installer_file} (WINEPREFIX={self.directory})", "success") # Set Windows version wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" env = os.environ.copy() env["WINEPREFIX"] = self.directory self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) # Run installer wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" env["WINEDEBUG"] = "-all" self.log("Launching installer with custom Wine...", "info") self.log("Follow the installation wizard in the window that opens.", "info") self.log("Click 'No' if you encounter any errors.", "warning") # Run installer and wait until it finishes, capturing logs (with fallback) success = self._run_installer_and_capture(installer_file, env, label="installer") if not success and not self.check_cancelled(): self.log("Installer process exited with a non-zero status", "warning") else: self.log("Installer succes.") # Clean up installer # if installer_file.exists(): # installer_file.unlink() # self.log("Installer file removed", "success") # Restore WinMetadata self.restore_winmetadata() # If it's an Affinity app, automatically create desktop entry and configure OpenCL if app_name in ["Photo", "Designer", "Publisher"]: self.log(f"Detected Affinity app: {app_name}, configuring...", "info") # Wait a bit more to ensure installation is fully complete time.sleep(2) # Configure OpenCL for Affinity apps (if enabled) if self.is_opencl_enabled(): self.configure_opencl(app_name) # Verify app path exists before creating desktop entry app_names = { "Photo": ("Photo", "Photo.exe", "Photo 2"), "Designer": ("Designer", "Designer.exe", "Designer 2"), "Publisher": ("Publisher", "Publisher.exe", "Publisher 2") } name, exe, dir_name = app_names.get(app_name, ("", "", "")) app_path = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / dir_name / exe if app_path.exists(): self.log(f"Found application at: {app_path}", "success") # Automatically create desktop entry # Call directly - create_desktop_entry uses signals so it's thread-safe try: self.create_desktop_entry(app_name) self.log("Desktop entry created successfully", "success") except Exception as e: self.log(f"Error creating desktop entry: {e}", "error") else: self.log(f"Warning: Application not found at expected path: {app_path}", "warning") self.log("Desktop entry will not be created automatically.", "warning") display_name = { "Photo": "Affinity Photo", "Designer": "Affinity Designer", "Publisher": "Affinity Publisher" }.get(app_name, app_name) self.log(f"\n✓ {display_name} installation completed!", "success") self.log("You can now launch it from your application menu.", "info") self.show_message( "Installation Complete", f"{display_name} has been successfully installed!\n\n" "You can launch it from your application menu.", "info" ) else: # For non-Affinity apps, just complete without desktop entry display_name = app_name if app_name else "Application" self.log(f"\n✓ {display_name} installation completed!", "success") self.show_message( "Installation Complete", f"{display_name} has been successfully installed!\n\n" "You may need to create a desktop entry manually if needed.", "info" ) except Exception as e: self.log(f"Installation error: {e}", "error") self.show_message("Installation Error", f"An error occurred:\n{e}", "error") def create_custom_desktop_entry(self, installer_path, app_name): """Create desktop entry for custom installed app""" reply = QMessageBox.question( self, "Create Desktop Entry", f"Would you like to create a desktop entry for '{app_name}'?\n\n" "You'll need to provide the executable path.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: return # Ask for executable path exe_path, ok = QInputDialog.getText( self, "Executable Path", f"Enter the full path to the {app_name} executable:\n\n" "Example: C:\\Program Files\\MyApp\\MyApp.exe" ) if not ok or not exe_path: self.log("Desktop entry creation cancelled.", "warning") return # Ask for icon path (optional) icon_path, ok = QInputDialog.getText( self, "Icon Path (Optional)", "Enter the path to an icon file (optional):\n\n" "Leave blank to use default icon." ) desktop_dir = Path.home() / ".local" / "share" / "applications" desktop_dir.mkdir(parents=True, exist_ok=True) desktop_file = desktop_dir / f"{app_name.replace(' ', '')}.desktop" wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" # Normalize all paths to strings to avoid double slashes wine_str = str(wine) directory_str = str(self.directory).rstrip("/") # Remove trailing slash if present # Normalize path: convert Windows backslashes to forward slashes, remove double slashes exe_path_normalized = exe_path.replace("\\", "/").replace("//", "/") # If it's a Windows path starting with C:, convert to Linux path if exe_path_normalized.startswith("C:/"): exe_path_normalized = directory_str + "/drive_c" + exe_path_normalized[2:] # Get GPU environment variables if configured gpu_env = self.get_gpu_env_vars() with open(desktop_file, "w") as f: f.write("[Desktop Entry]\n") f.write(f"Name={app_name}\n") f.write(f"Comment={app_name} installed via Affinity Linux Installer\n") if icon_path: icon_path_str = str(icon_path).rstrip("/") f.write(f"Icon={icon_path_str}\n") f.write(f"Path={directory_str}\n") # Use Linux path format with proper quoting for spaces # Include GPU environment variables if configured exec_line = f'Exec=env WINEPREFIX={directory_str}' if gpu_env: exec_line += f' {gpu_env}' exec_line += f' {wine_str} "{exe_path_normalized}"' f.write(f'{exec_line}\n') f.write("Terminal=false\n") f.write("Type=Application\n") f.write("Categories=Application;\n") f.write("StartupNotify=true\n") self.log(f"Desktop entry created: {desktop_file}", "success") def update_application(self, app_name): """Update Affinity application - simple installer that assumes everything is set up""" app_names = { "Add": "Affinity (Unified)", "Photo": "Affinity Photo", "Designer": "Affinity Designer", "Publisher": "Affinity Publisher" } display_name = app_names.get(app_name, app_name) self.log(f"\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log(f"Update {display_name}", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if Wine is set up wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") return # Ask for installer file self.log(f"Please select the {display_name} installer (.exe)...", "info") installer_path, _ = QFileDialog.getOpenFileName( self, f"Select {display_name} Installer", "", "Executable files (*.exe);;All files (*.*)" ) if not installer_path: self.log("Update cancelled.", "warning") return # Start operation and update in thread self.start_operation(f"Update {display_name}") threading.Thread( target=self._run_update_entry, args=(display_name, installer_path), daemon=True ).start() def _run_update_entry(self, display_name, installer_path): """Wrapper: run update and always end operation.""" try: self.run_update(display_name, installer_path) finally: self.end_operation() def run_update(self, display_name, installer_path): """Run the update process - simple installer without desktop entries or deps""" try: self.update_progress_text("Preparing update...") self.update_progress(0.0) self.log(f"Selected installer: {installer_path}", "success") # Copy installer to Wine prefix with sanitized filename (remove spaces) self.update_progress_text("Copying installer...") self.update_progress(0.2) original_filename = Path(installer_path).name sanitized_filename = self.sanitize_filename(original_filename) installer_file = Path(self.directory) / sanitized_filename shutil.copy2(installer_path, installer_file) self.log(f"Installer copied to Wine prefix: {installer_file} (WINEPREFIX={self.directory})", "success") # Set up environment self.update_progress_text("Configuring Wine...") self.update_progress(0.3) env = os.environ.copy() env["WINEPREFIX"] = self.directory env["WINEDEBUG"] = "-all" # Run installer with custom Wine self.update_progress_text("Running updater...") self.update_progress(0.4) wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" self.log("Launching installer...", "info") self.log("Follow the installation wizard in the window that opens.", "info") self.log("This will update the application without creating desktop entries.", "info") # Run updater and wait, capturing logs (with fallback) success = self._run_installer_and_capture(installer_file, env, label="updater") if not success and not self.check_cancelled(): self.log("Updater process exited with a non-zero status", "warning") # Clean up installer if installer_file.exists(): installer_file.unlink() self.log("Installer file removed", "success") # Remove Wine desktop entries created by the installer desktop_dir = Path.home() / ".local" / "share" / "applications" wine_desktop_dir = desktop_dir / "wine" / "Programs" # Ensure display_name is a string if not isinstance(display_name, str): display_name = str(display_name) if display_name is not None else "" # Map display names to possible Wine desktop entry names wine_entry_names = [] if display_name and ("Unified" in display_name or display_name == "Affinity (Unified)"): wine_entry_names = ["Affinity.desktop"] elif display_name and "Photo" in display_name: wine_entry_names = ["Affinity Photo 2.desktop", "Affinity Photo.desktop"] elif display_name and "Designer" in display_name: wine_entry_names = ["Affinity Designer 2.desktop", "Affinity Designer.desktop"] elif display_name and "Publisher" in display_name: wine_entry_names = ["Affinity Publisher 2.desktop", "Affinity Publisher.desktop"] removed_count = 0 for entry_name in wine_entry_names: wine_entry = wine_desktop_dir / entry_name if wine_entry.exists(): try: wine_entry.unlink() removed_count += 1 self.log(f"Removed Wine desktop entry: {entry_name}", "info") except Exception as e: self.log(f"Could not remove {entry_name}: {e}", "error") # Also check for generic Affinity.desktop if not already checked if display_name and "Unified" not in display_name: generic_entry = wine_desktop_dir / "Affinity.desktop" if generic_entry.exists(): try: generic_entry.unlink() self.log("Removed Wine desktop entry: Affinity.desktop", "info") except Exception as e: self.log(f"Could not remove Affinity.desktop: {e}", "error") if removed_count > 0: self.log(f"Cleaned up {removed_count} Wine desktop entr{'y' if removed_count == 1 else 'ies'}", "success") # Reinstall WinMetadata to avoid corruption self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Reinstalling WinMetadata to prevent corruption...", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Kill Wine processes before removing WinMetadata self.log("Stopping Wine processes...", "info") self.run_command(["wineserver", "-k"], check=False) time.sleep(2) system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32" winmetadata_dir = system32_dir / "WinMetadata" # Remove existing WinMetadata folder if winmetadata_dir.exists(): self.log("Removing existing WinMetadata folder...", "info") try: shutil.rmtree(winmetadata_dir) self.log("Old WinMetadata folder removed", "success") except Exception as e: self.log(f"Warning: Could not fully remove old folder: {e}", "warning") # Also remove the zip file to force re-download winmetadata_zip = Path(self.directory) / "Winmetadata.zip" if winmetadata_zip.exists(): self.log("Removing cached WinMetadata.zip to force fresh download...", "info") try: winmetadata_zip.unlink() self.log("Cached zip file removed", "success") except Exception as e: self.log(f"Warning: Could not remove cached zip: {e}", "warning") # Reinstall WinMetadata self.log("Installing fresh WinMetadata...", "info") self.setup_winmetadata() # For Affinity v3 (Unified), check and install WebView2 Runtime if needed, then reinstall settings files if display_name and ("Unified" in display_name or display_name == "Affinity (Unified)"): # Check if WebView2 Runtime is installed, install if missing self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Checking WebView2 Runtime for Affinity v3...", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") if not self.check_webview2_installed(): self.log("WebView2 Runtime not found. Installing automatically...", "info") self._install_webview2_runtime_thread() # Install synchronously in this thread else: self.log("WebView2 Runtime is already installed.", "success") # Reinstall settings files self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Reinstalling Affinity v3 settings files...", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") self._install_affinity_settings_thread() # Patch the DLL to fix settings saving (this is the last step) self.update_progress_text("Patching DLL for settings fix...") self.update_progress(0.95) patch_success = self.patch_affinity_dll(display_name) if patch_success: self.log("Settings fix patch applied successfully", "success") else: self.log("Settings fix patch was skipped or failed (check log for details)", "warning") self.update_progress(1.0) self.update_progress_text("Update complete!") self.log(f"\n✓ {display_name} update completed!", "success") self.log("The application has been updated. Use your existing desktop entry to launch it.", "info") # Refresh installation status to update button states QTimer.singleShot(100, self.check_installation_status) message_text = f"{display_name} has been successfully updated!\n\n" message_text += "WinMetadata has been reinstalled to prevent corruption.\n" if display_name and ("Unified" in display_name or display_name == "Affinity (Unified)"): message_text += "Affinity v3 settings have been reinstalled.\n" message_text += "Settings fix patch has been applied (settings should now save properly).\n" message_text += "Use your existing desktop entry to launch the application." self.show_message( "Update Complete", message_text, "info" ) except Exception as e: self.log(f"Update error: {e}", "error") self.show_message("Update Error", f"An error occurred:\n{e}", "error") def _run_installation_entry(self, app_name, installer_path): """Wrapper: run installation and always end operation.""" try: self.run_installation(app_name, installer_path) finally: self.end_operation() def run_installation(self, app_name, installer_path): """Run the installation process""" try: self.update_progress_text("Preparing installation...") self.update_progress(0.0) self.log(f"Selected installer: {installer_path}", "success") # Copy installer with sanitized filename (remove spaces) self.update_progress_text("Copying installer...") self.update_progress(0.1) original_filename = Path(installer_path).name sanitized_filename = self.sanitize_filename(original_filename) installer_file = Path(self.directory) / sanitized_filename shutil.copy2(installer_path, installer_file) self.log(f"Installer copied to Wine prefix: {installer_file} (WINEPREFIX={self.directory})", "success") # Set Windows version self.update_progress_text("Configuring Wine...") self.update_progress(0.2) wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" env = os.environ.copy() env["WINEPREFIX"] = self.directory self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) # Run installer self.update_progress_text("Running installer...") self.update_progress(0.3) wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" env["WINEDEBUG"] = "-all" self.log("Launching installer...", "info") self.log("Follow the installation wizard in the window that opens.", "info") self.log("Click 'No' if you encounter any errors.", "warning") # Run installer and wait, capturing logs (with fallback) success = self._run_installer_and_capture(installer_file, env, label="installer") if not success and not self.check_cancelled(): self.log("Installer process exited with a non-zero status", "warning") # Clean up installer self.update_progress(0.5) installer_file.unlink() self.log("Installer file removed", "success") # Restore WinMetadata self.update_progress_text("Restoring Windows Metadata...") self.update_progress(0.6) self.restore_winmetadata() # Configure OpenCL (if enabled) if self.is_opencl_enabled(): self.update_progress_text("Configuring OpenCL...") self.update_progress(0.7) self.configure_opencl(app_name) else: self.log("OpenCL support is disabled, skipping configuration", "info") # For Affinity v3 (Unified), check and install WebView2 Runtime if needed if app_name == "Add" or app_name == "Affinity (Unified)": self.update_progress_text("Checking WebView2 Runtime...") self.update_progress(0.8) self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Checking WebView2 Runtime for Affinity v3...", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") if not self.check_webview2_installed(): self.log("WebView2 Runtime not found. Installing automatically...", "info") self._install_webview2_runtime_thread() # Install synchronously in this thread else: self.log("WebView2 Runtime is already installed.", "success") # Patch the DLL to fix settings saving self.update_progress_text("Patching DLL for settings fix...") self.update_progress(0.85) self.patch_affinity_dll(app_name) # Create desktop entry self.update_progress_text("Creating desktop entry...") self.update_progress(0.9) self.create_desktop_entry(app_name) self.update_progress(1.0) self.update_progress_text("Installation complete!") self.log(f"\n✓ {app_name} installation completed!", "success") self.log("You can now launch it from your application menu.", "info") self.show_message( "Installation Complete", f"{app_name} has been successfully installed!\n\n" "You can launch it from your application menu.", "info" ) except Exception as e: self.log(f"Installation error: {e}", "error") self.show_message("Installation Error", f"An error occurred:\n{e}", "error") def restore_winmetadata(self): """Restore WinMetadata after installation""" self.log("Restoring Windows metadata files...", "info") # Kill Wine processes self.run_command(["wineserver", "-k"], check=False) time.sleep(2) winmetadata_zip = Path(self.directory) / "Winmetadata.zip" system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32" if winmetadata_zip.exists(): # Re-extract try: if self.check_command("7z"): self.run_command([ "7z", "x", str(winmetadata_zip), f"-o{system32_dir}", "-y" ], check=False) elif self.check_command("unzip"): with zipfile.ZipFile(winmetadata_zip, 'r') as zip_ref: zip_ref.extractall(system32_dir) self.log("WinMetadata restored", "success") except Exception as e: self.log(f"Failed to restore WinMetadata: {e}", "warning") else: # Re-download if not cached self.log("WinMetadata.zip not found, downloading...", "info") self.setup_winmetadata() def is_opencl_enabled(self): """Check if OpenCL is enabled""" # First check instance variable (set during one-click setup) if hasattr(self, 'enable_opencl') and self.enable_opencl: return True # Check saved preference opencl_config_file = Path(self.directory) / ".opencl_enabled" if opencl_config_file.exists(): try: with open(opencl_config_file, 'r') as f: content = f.read().strip() return content == "1" except Exception: pass return False def get_renderer_setting(self): """Get the current renderer setting from registry (vulkan, opengl, or gdi)""" try: wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine.exists(): return "vulkan" # Default to vulkan if Wine not set up env = os.environ.copy() env["WINEPREFIX"] = self.directory success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer"], check=False, env=env, capture=True ) if success and stdout: stdout_lower = stdout.lower() if "opengl" in stdout_lower: return "opengl" elif "gdi" in stdout_lower: return "gdi" elif "vulkan" in stdout_lower: return "vulkan" # Default to vulkan if not found return "vulkan" except Exception: return "vulkan" # Default to vulkan on error def configure_opencl(self, app_name): """Configure OpenCL for application""" # Only configure if OpenCL is enabled if not self.is_opencl_enabled(): self.log("OpenCL support is disabled, skipping configuration", "info") return app_dirs = { "Photo": "Photo 2", "Designer": "Designer 2", "Publisher": "Publisher 2", "Add": "Affinity" } app_dir_name = app_dirs.get(app_name, "Affinity") app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / app_dir_name if not app_dir.exists(): self.log(f"Application directory not found: {app_dir}", "warning") return wine_lib_dir = Path(self.directory) / "ElementalWarriorWine" / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows" vkd3d_temp = Path(self.directory) / "vkd3d_dlls" dlls_copied = 0 for dll in ["d3d12.dll", "d3d12core.dll"]: for source in [vkd3d_temp / dll, wine_lib_dir / dll]: if source.exists(): shutil.copy2(source, app_dir / dll) self.log(f"Copied {dll}", "success") dlls_copied += 1 break # Configure DLL overrides reg_file = Path(self.directory) / "dll_overrides.reg" with open(reg_file, "w") as f: f.write("REGEDIT4\n") f.write("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]\n") f.write('"d3d12"="native"\n') f.write('"d3d12core"="native"\n') regedit = Path(self.directory) / "ElementalWarriorWine" / "bin" / "regedit" env = os.environ.copy() env["WINEPREFIX"] = self.directory self.run_command([str(regedit), str(reg_file)], check=False, env=env) reg_file.unlink() if dlls_copied > 0: self.log("OpenCL support configured", "success") def _parse_version(self, version_str): """Parse version string and return tuple of (major, minor, patch) for comparison""" try: # Remove any non-numeric suffixes (e.g., "8.0.100-preview" -> "8.0.100") version_str = version_str.strip().split('-')[0].split('+')[0] parts = version_str.split('.') major = int(parts[0]) if len(parts) > 0 else 0 minor = int(parts[1]) if len(parts) > 1 else 0 patch = int(parts[2]) if len(parts) > 2 else 0 return (major, minor, patch) except (ValueError, IndexError): return (0, 0, 0) def _is_version_sufficient(self, version_str, min_major=8): """Check if version is 8.0 or newer""" major, minor, patch = self._parse_version(version_str) return major >= min_major def check_dotnet_sdk(self): """Check if .NET SDK version 8.0 or newer is installed""" # First, try to run dotnet --version success, stdout, _ = self.run_command( ["dotnet", "--version"], check=False, capture=True ) if success and stdout: version = stdout.strip() if self._is_version_sufficient(version): self.log(f".NET SDK found (version {version}) - using installed version", "success") return True else: self.log(f".NET SDK found but version {version} is too old (need 8.0+)", "warning") # If dotnet command not found, check if it's installed via package manager # This is useful when dotnet is installed but not in PATH if self.distro in ["fedora", "nobara"]: # Check for any dotnet-sdk package (not just 8.0) success, stdout, _ = self.run_command( ["dnf", "list", "installed", "dotnet-sdk*"], check=False, capture=True ) if success and stdout: # Look for any dotnet-sdk package for line in stdout.split('\n'): if 'dotnet-sdk' in line.lower() and 'installed' in line.lower(): # Extract version from package name (e.g., dotnet-sdk-8.0, dotnet-sdk-9.0) match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line) if match: major = int(match.group(1)) if major >= 8: self.log(f".NET SDK package found via dnf: {line.split()[0]}", "success") # Try to find dotnet in common locations common_paths = [ "/usr/bin/dotnet", "/usr/local/bin/dotnet", "/opt/dotnet/dotnet", Path.home() / ".dotnet" / "dotnet" ] for path in common_paths: if Path(path).exists(): # Try running it success, stdout, _ = self.run_command( [str(path), "--version"], check=False, capture=True ) if success and stdout: version = stdout.strip() if self._is_version_sufficient(version): self.log(f".NET SDK found at {path}: {version} - using installed version", "success") return True # Package is installed but dotnet command not accessible self.log(".NET SDK package is installed but 'dotnet' command not found in PATH", "warning") self.log("You may need to add /usr/bin to your PATH or restart your terminal", "info") return True # Return True anyway since package is installed elif self.distro in ["arch", "cachyos", "endeavouros", "xerolinux"]: # Check for any dotnet-sdk package via pacman # Query all installed packages and filter for dotnet-sdk success, stdout, _ = self.run_command( ["pacman", "-Q"], check=False, capture=True ) if success and stdout: # Check all installed packages for dotnet-sdk for line in stdout.split('\n'): if 'dotnet-sdk' in line.lower(): # Extract version from package name (e.g., dotnet-sdk-8.0, dotnet-sdk-9.0) match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line) if match: major = int(match.group(1)) if major >= 8: package_name = line.split()[0] self.log(f".NET SDK package found via pacman: {package_name}", "success") # Try common paths common_paths = ["/usr/bin/dotnet", "/usr/local/bin/dotnet"] for path in common_paths: if Path(path).exists(): success, stdout, _ = self.run_command( [path, "--version"], check=False, capture=True ) if success and stdout: version = stdout.strip() if self._is_version_sufficient(version): self.log(f".NET SDK found at {path}: {version} - using installed version", "success") return True return True # Package is installed elif self.distro in ["pikaos", "pop", "debian"]: # Check for any dotnet-sdk package via dpkg success, stdout, _ = self.run_command( ["dpkg", "-l", "dotnet-sdk*"], check=False, capture=True ) if success and stdout: # Look for any dotnet-sdk package for line in stdout.split('\n'): if 'dotnet-sdk' in line.lower() and line.startswith('ii'): # Extract version from package name (e.g., dotnet-sdk-8.0, dotnet-sdk-9.0) match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line) if match: major = int(match.group(1)) if major >= 8: self.log(f".NET SDK package found via dpkg: {line.split()[1]}", "success") common_paths = ["/usr/bin/dotnet", "/usr/local/bin/dotnet"] for path in common_paths: if Path(path).exists(): success, stdout, _ = self.run_command( [path, "--version"], check=False, capture=True ) if success and stdout: version = stdout.strip() if self._is_version_sufficient(version): self.log(f".NET SDK found at {path}: {version} - using installed version", "success") return True return True # Package is installed return False def ensure_patcher_files(self, silent=False): """Ensure AffinityPatcher files are available in .AffinityLinux/Patch/""" try: # Source: repo's Patch directory script_dir = Path(__file__).parent source_patch_dir = script_dir.parent / "Patch" # Destination: .AffinityLinux/Patch/ dest_patch_dir = Path(self.directory) / "Patch" dest_patch_dir.mkdir(parents=True, exist_ok=True) # Files to copy files_to_copy = ["AffinityPatcher.cs", "AffinityPatcher.csproj"] files_copied = False all_exist = True for filename in files_to_copy: source_file = source_patch_dir / filename dest_file = dest_patch_dir / filename if source_file.exists(): # Only copy if destination doesn't exist or source is newer if not dest_file.exists() or source_file.stat().st_mtime > dest_file.stat().st_mtime: shutil.copy2(source_file, dest_file) files_copied = True if not silent: self.log(f"Copied {filename} to .AffinityLinux/Patch/", "info") else: if not silent: self.log(f"Warning: {filename} not found in source Patch directory", "warning") all_exist = False # Check if destination file exists after copy attempt if not dest_file.exists(): all_exist = False if files_copied and not silent: self.log("Patcher files are ready in .AffinityLinux/Patch/", "success") elif not all_exist and not silent: self.log("Some patcher files are missing", "warning") return all_exist except Exception as e: if not silent: self.log(f"Error ensuring patcher files: {e}", "error") return False def build_affinity_patcher(self): """Build the AffinityPatcher .NET project""" # Use Patch directory from .AffinityLinux (ensured to be available) patch_dir = Path(self.directory) / "Patch" if not patch_dir.exists(): self.log(f"Patch directory not found: {patch_dir}", "error") return None csproj_file = patch_dir / "AffinityPatcher.csproj" if not csproj_file.exists(): self.log(f"AffinityPatcher.csproj not found: {csproj_file}", "error") return None self.log(f"Building AffinityPatcher from: {patch_dir}", "info") # Build the project output_dir = patch_dir / "bin" / "Release" success, stdout, stderr = self.run_command( ["dotnet", "build", str(csproj_file), "-c", "Release", "-o", str(output_dir)], check=False, capture=True ) if not success: self.log(f"Failed to build AffinityPatcher: {stderr}", "error") if stdout: self.log(f"Build output: {stdout}", "warning") return None # Find the built executable - .NET can create different output formats # Try common output names possible_names = [ "AffinityPatcher", # Native executable (Linux) "AffinityPatcher.dll", # DLL (runnable with dotnet) "AffinityPatcher.exe", # Windows executable (unlikely on Linux) ] patcher_exe = None for name in possible_names: candidate = output_dir / name if candidate.exists(): patcher_exe = candidate break if patcher_exe and patcher_exe.exists(): self.log(f"AffinityPatcher built successfully: {patcher_exe}", "success") return patcher_exe else: # List what's actually in the output directory for debugging if output_dir.exists(): files = list(output_dir.glob("*")) self.log(f"Files in output directory: {[f.name for f in files]}", "warning") self.log(f"Built patcher not found at expected location: {output_dir}", "error") return None def run_affinity_patcher(self, dll_path): """Run the AffinityPatcher on the specified DLL""" if not Path(dll_path).exists(): self.log(f"DLL not found: {dll_path}", "error") return False # Build the patcher if needed patcher_exe = self.build_affinity_patcher() if not patcher_exe: self.log("Failed to build AffinityPatcher", "error") return False self.log(f"Running AffinityPatcher on: {dll_path}", "info") # Run the patcher - use dotnet for DLLs, direct execution for native executables if patcher_exe.suffix == ".dll": cmd = ["dotnet", str(patcher_exe), dll_path] else: cmd = [str(patcher_exe), dll_path] success, stdout, stderr = self.run_command( cmd, check=False, capture=True ) if success: self.log("AffinityPatcher completed successfully", "success") if stdout: # Log the patcher output for line in stdout.strip().split('\n'): if line.strip(): if "SUCCESS" in line or "success" in line.lower(): self.log(line, "success") elif "ERROR" in line or "error" in line.lower(): self.log(line, "error") else: self.log(line, "info") return True else: self.log(f"AffinityPatcher failed: {stderr}", "error") if stdout: self.log(f"Output: {stdout}", "warning") return False def patch_affinity_dll(self, app_name): """Patch the Serif.Affinity.dll for Affinity v3 (Unified)""" # Only patch Affinity v3 (Unified) if app_name != "Add" and app_name != "Affinity (Unified)": return True # Not applicable, return success self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Patching Affinity DLL for settings fix...", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if .NET SDK is available, try to install if missing if not self.check_dotnet_sdk(): self.log(".NET SDK not found. Attempting to install...", "info") if not self.install_dotnet_sdk(): self.log("Failed to install .NET SDK automatically", "warning") self.log("Settings patching will be skipped.", "warning") self.log("You can install .NET SDK manually:", "info") if self.distro in ["arch", "cachyos"]: self.log(" sudo pacman -S dotnet-sdk-8.0", "info") elif self.distro in ["endeavouros", "xerolinux"]: self.log(" sudo pacman -S dotnet-sdk-8.0", "info") elif self.distro in ["fedora", "nobara"]: self.log(" sudo dnf install dotnet-sdk-8.0", "info") elif self.distro in ["pikaos", "pop", "debian"]: self.log(" sudo apt install dotnet-sdk-8.0", "info") self.log(" (May require Microsoft's .NET repository)", "warning") elif self.distro in ["opensuse-tumbleweed", "opensuse-leap"]: self.log(" sudo zypper install dotnet-sdk-8.0", "info") return False else: self.log(".NET SDK installed successfully", "success") # Find the DLL dll_path = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / "Affinity" / "Serif.Affinity.dll" if not dll_path.exists(): self.log(f"Serif.Affinity.dll not found at: {dll_path}", "warning") self.log("The DLL may not be installed yet. Patching will be skipped.", "warning") return False # Run the patcher return self.run_affinity_patcher(str(dll_path)) def create_desktop_entry(self, app_name): """Create desktop entry for application""" app_names = { "Photo": ("Photo", "Photo.exe", "Photo 2", "AffinityPhoto.svg"), "Designer": ("Designer", "Designer.exe", "Designer 2", "AffinityDesigner.svg"), "Publisher": ("Publisher", "Publisher.exe", "Publisher 2", "AffinityPublisher.svg"), "Add": ("Affinity", "Affinity.exe", "Affinity", "Affinity.svg") } name, exe, dir_name, icon = app_names.get(app_name, ("Affinity", "Affinity.exe", "Affinity", "Affinity.svg")) desktop_dir = Path.home() / ".local" / "share" / "applications" desktop_dir.mkdir(parents=True, exist_ok=True) desktop_file = desktop_dir / f"Affinity{name}.desktop" if app_name == "Add": desktop_file = desktop_dir / "Affinity.desktop" wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" app_path = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / dir_name / exe icon_path = Path.home() / ".local" / "share" / "icons" / icon # Normalize all paths to strings to avoid double slashes wine_str = str(wine) directory_str = str(self.directory).rstrip("/") # Remove trailing slash if present icon_path_str = str(icon_path) app_path_str = str(app_path).replace("\\", "/") # Ensure forward slashes, no double slashes # Get GPU environment variables if configured gpu_env = self.get_gpu_env_vars() with open(desktop_file, "w") as f: f.write("[Desktop Entry]\n") f.write(f"Name=Affinity {name}\n") f.write(f"Comment=A powerful {name.lower()} software.\n") f.write(f"Icon={icon_path_str}\n") f.write(f"Path={directory_str}\n") # Use Linux path format with proper quoting for spaces # Include GPU environment variables if configured exec_line = f'Exec=env WINEPREFIX={directory_str}' if gpu_env: exec_line += f' {gpu_env}' exec_line += f' {wine_str} "{app_path_str}"' f.write(f'{exec_line}\n') f.write("Terminal=false\n") f.write("Type=Application\n") f.write("Categories=Graphics;\n") f.write("StartupNotify=true\n") if app_name == "Add": f.write("StartupWMClass=affinity.exe\n") else: f.write(f"StartupWMClass={name.lower()}.exe\n") # Remove Wine's default entry wine_entry = desktop_dir / "wine" / "Programs" / f"Affinity {name} 2.desktop" if wine_entry.exists(): wine_entry.unlink() if app_name == "Add": wine_entry = desktop_dir / "wine" / "Programs" / "Affinity.desktop" if wine_entry.exists(): wine_entry.unlink() # Create desktop shortcut desktop_shortcut = Path.home() / "Desktop" / desktop_file.name if desktop_shortcut.parent.exists(): shutil.copy2(desktop_file, desktop_shortcut) self.log(f"Desktop entry created: {desktop_file}", "success") self.log("Desktop shortcut created", "success") def _download_affinity_installer_thread(self, save_path_obj: Path): """Worker: Download Affinity installer and end operation.""" download_url = "https://downloads.affinity.studio/Affinity%20x64.exe" self.log(f"Downloading from: {download_url}", "info") self.log(f"Saving to: {save_path_obj}", "info") try: if self.download_file(download_url, str(save_path_obj), "Affinity installer"): self.log(f"\n✓ Download completed successfully!", "success") self.log(f"Installer saved to: {save_path_obj}", "success") self.show_message( "Download Complete", "Affinity installer has been downloaded successfully!\n\nYou can now run it with the installer buttons.", "info" ) else: self.log("✗ Download failed", "error") finally: self.end_operation() def open_winecfg(self): """Open Wine Configuration tool using custom Wine""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Opening Wine Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" if not wine_cfg.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") return env = os.environ.copy() env["WINEPREFIX"] = self.directory self.log(f"Opening winecfg using: {wine_cfg}", "info") self.log("The Wine Configuration window should open now.", "info") # Run winecfg in background (non-blocking) threading.Thread( target=lambda: self.run_command([str(wine_cfg)], check=False, capture=False, env=env), daemon=True ).start() self.log("✓ Wine Configuration opened", "success") def open_winetricks(self): """Open Winetricks GUI using custom Wine""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Opening Winetricks", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" if not wine_cfg.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") return # Check if winetricks is available winetricks_path = shutil.which("winetricks") if not winetricks_path: self.log("Winetricks is not installed. Please install it using your package manager.", "error") self.show_message( "Winetricks Not Found", "Winetricks is not installed. Please install it using:\n\n" "Arch/CachyOS/EndeavourOS/XeroLinux: sudo pacman -S winetricks\n" "Fedora/Nobara: sudo dnf install winetricks\n" "Debian/Ubuntu/Mint/Pop/Zorin/PikaOS: sudo apt install winetricks\n" "openSUSE: sudo zypper install winetricks", "error" ) return env = os.environ.copy() env["WINEPREFIX"] = self.directory self.log(f"Opening winetricks using: {winetricks_path}", "info") self.log("The Winetricks GUI should open now.", "info") # Run winetricks in background (non-blocking) # Winetricks will open its GUI when run without arguments threading.Thread( target=lambda: self.run_command([winetricks_path], check=False, capture=False, env=env), daemon=True ).start() self.log("✓ Winetricks opened", "success") def set_windows11_renderer(self): """Set Windows 11 and configure renderer (OpenGL or Vulkan)""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Windows 11 + Renderer Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Start operation for renderer configuration self.start_operation("Configure Renderer") wine_cfg = Path(self.directory) / "ElementalWarriorWine" / "bin" / "winecfg" if not wine_cfg.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.end_operation() return # Ask user to choose renderer dialog = QDialog(self) dialog.setWindowTitle("Select Renderer") dialog.setMinimumWidth(300) layout = QVBoxLayout(dialog) label = QLabel("Choose a renderer for troubleshooting:") layout.addWidget(label) button_group = QButtonGroup() vulkan_radio = QRadioButton("Vulkan (Recommended - OpenCL support)") vulkan_radio.setChecked(True) opengl_radio = QRadioButton("OpenGL (Alternative)") gdi_radio = QRadioButton("GDI (Fallback)") button_group.addButton(vulkan_radio, 0) button_group.addButton(opengl_radio, 1) button_group.addButton(gdi_radio, 2) layout.addWidget(vulkan_radio) layout.addWidget(opengl_radio) layout.addWidget(gdi_radio) buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() != QDialog.DialogCode.Accepted: self.log("Renderer configuration cancelled", "warning") self.end_operation() return # Determine selected renderer renderer_map = { 0: ("vulkan", "Vulkan"), 1: ("opengl", "OpenGL"), 2: ("gdi", "GDI") } selected_id = button_group.checkedId() renderer_value, renderer_name = renderer_map.get(selected_id, ("vulkan", "Vulkan")) env = os.environ.copy() env["WINEPREFIX"] = self.directory # Set Windows version to 11 self.log("Setting Windows version to 11...", "info") success, _, _ = self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env) if success: self.log("✓ Windows version set to 11", "success") else: self.log("⚠ Warning: Failed to set Windows version", "warning") # Set renderer directly via registry (more reliable than winetricks) self.log(f"Configuring {renderer_name} renderer...", "info") wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" # Set renderer directly via registry - this is more reliable than winetricks self.log(f"Setting {renderer_name} renderer via registry...", "info") reg_add_success, reg_add_stdout, reg_add_stderr = self.run_command( [str(wine), "reg", "add", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer", "/t", "REG_SZ", "/d", renderer_value, "/f"], check=False, env=env, capture=True ) if reg_add_success: self.log(f"✓ {renderer_name} renderer set via registry", "success") else: # Fallback to winetricks if direct registry setting fails self.log(f"Registry method failed, trying winetricks...", "info") success, stdout, stderr = self.run_command( ["winetricks", "--unattended", "--force", "--no-isolate", "--optout", f"renderer={renderer_value}"], check=False, env=env ) if success: self.log(f"✓ {renderer_name} renderer set via winetricks", "success") else: self.log(f"⚠ Warning: Failed to set {renderer_name} renderer via both methods", "warning") # Verify renderer was actually set in registry self.log(f"Verifying {renderer_name} renderer configuration...", "info") renderer_verified = False try: # Check registry for renderer setting renderer_check_success, renderer_check_stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer"], check=False, env=env, capture=True ) if renderer_check_success and renderer_check_stdout: renderer_check_lower = renderer_check_stdout.lower() # Check for the renderer value we set if renderer_value == "vulkan" and "vulkan" in renderer_check_lower: renderer_verified = True elif renderer_value == "opengl" and "opengl" in renderer_check_lower: renderer_verified = True elif renderer_value == "gdi" and "gdi" in renderer_check_lower: renderer_verified = True if renderer_verified: self.log(f"✓ {renderer_name} renderer verified in registry", "success") else: # Show what was actually found actual_renderer = None if "renderer" in renderer_check_lower: # Extract the actual value match = re.search(r'renderer\s+REG_SZ\s+(\w+)', renderer_check_stdout, re.IGNORECASE) if match: actual_renderer = match.group(1) self.log(f"⚠ Warning: Expected {renderer_name} but found {actual_renderer} in registry", "warning") else: self.log(f"⚠ Warning: {renderer_name} renderer may not be set correctly", "warning") else: self.log(f"⚠ Warning: Could not verify {renderer_name} renderer in registry", "warning") # Retry setting the renderer if verification failed if not actual_renderer or actual_renderer.lower() != renderer_value.lower(): self.log(f"Retrying to set {renderer_name} renderer via registry...", "info") retry_success, _, retry_stderr = self.run_command( [str(wine), "reg", "add", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer", "/t", "REG_SZ", "/d", renderer_value, "/f"], check=False, env=env, capture=True ) if retry_success: # Verify again after retry verify_success, verify_stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D", "/v", "renderer"], check=False, env=env, capture=True ) if verify_success and renderer_value.lower() in (verify_stdout or "").lower(): self.log(f"✓ {renderer_name} renderer set successfully via registry", "success") else: self.log(f"⚠ Warning: Failed to verify {renderer_name} renderer after retry", "warning") else: self.log(f"⚠ Warning: Failed to set {renderer_name} renderer via registry: {retry_stderr[:100] if retry_stderr else 'Unknown error'}", "warning") else: self.log(f"⚠ Warning: Could not read renderer from registry", "warning") except Exception as e: self.log(f"⚠ Warning: Error verifying renderer: {e}", "warning") self.log("\n✓ Windows 11 and renderer configuration completed", "success") self.end_operation() def install_dotnet_sdk(self): """Install .NET SDK based on distribution""" try: self.log("Installing .NET SDK...", "info") if self.distro in ["pikaos", "pop", "debian"]: # Try installing dotnet-sdk-8.0 (may need Microsoft repo) success, _, stderr = self.run_command([ "sudo", "apt", "install", "-y", "dotnet-sdk-8.0" ], check=False) if not success: self.log("Failed to install dotnet-sdk-8.0 from default repos", "warning") self.log("You may need to add Microsoft's .NET repository. See: https://learn.microsoft.com/dotnet/core/install/linux", "info") return False return True commands = { "arch": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-8.0"], "cachyos": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-8.0"], "endeavouros": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-8.0"], "xerolinux": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-8.0"], "fedora": ["sudo", "dnf", "install", "-y", "dotnet-sdk-8.0"], "nobara": ["sudo", "dnf", "install", "-y", "dotnet-sdk-8.0"], "opensuse-tumbleweed": ["sudo", "zypper", "install", "-y", "dotnet-sdk-8.0"], "opensuse-leap": ["sudo", "zypper", "install", "-y", "dotnet-sdk-8.0"] } if self.distro in commands: success, _, stderr = self.run_command(commands[self.distro], check=False) if success: self.log(".NET SDK installed successfully", "success") return True else: self.log(f"Failed to install .NET SDK: {stderr[:200] if stderr else 'Unknown error'}", "error") return False self.log(f"Unsupported distribution for .NET SDK auto-install: {self.format_distro_name()}", "error") return False except Exception as e: self.log(f"Error installing .NET SDK: {e}", "error") return False def fix_affinity_settings(self): """Fix Affinity v3 settings by patching the DLL""" try: self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Fix Affinity v3 Settings", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Ensure patcher files are available self.ensure_patcher_files() # Check if Affinity v3 is installed dll_path = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / "Affinity" / "Serif.Affinity.dll" if not dll_path.exists(): self.log("Affinity v3 (Unified) is not installed.", "error") self.log(f"Expected DLL at: {dll_path}", "info") self.show_message( "Affinity v3 Not Found", "Affinity v3 (Unified) is not installed.\n\n" "This fix only works for Affinity v3 (Unified).\n" "Please install Affinity v3 first using the 'Affinity (Unified)' button.", "error" ) return self.start_operation("Fix Affinity Settings") # Check if .NET SDK is installed, if not try to install it if not self.check_dotnet_sdk(): self.log(".NET SDK not found. Attempting to install...", "info") try: if not self.install_dotnet_sdk(): self.log("Failed to install .NET SDK automatically", "error") self.log("Please install .NET SDK manually:", "info") if self.distro in ["arch", "cachyos"]: self.log(" sudo pacman -S dotnet-sdk-8.0", "info") elif self.distro in ["endeavouros", "xerolinux"]: self.log(" sudo pacman -S dotnet-sdk-8.0", "info") elif self.distro in ["fedora", "nobara"]: self.log(" sudo dnf install dotnet-sdk-8.0", "info") elif self.distro in ["pikaos", "pop", "debian"]: self.log(" sudo apt install dotnet-sdk-8.0", "info") self.log(" (May require Microsoft's .NET repository)", "warning") elif self.distro in ["opensuse-tumbleweed", "opensuse-leap"]: self.log(" sudo zypper install dotnet-sdk-8.0", "info") self.end_operation() self.show_message( ".NET SDK Required", ".NET SDK is required to patch the Affinity DLL.\n\n" "Please install it manually using the commands shown in the log, then try again.", "error" ) return except Exception as e: self.log(f"Error during .NET SDK installation: {e}", "error") self.end_operation() self.show_message( "Installation Error", f"An error occurred while trying to install .NET SDK:\n{e}\n\n" "Please install .NET SDK manually and try again.", "error" ) return # Patch the DLL success = self.patch_affinity_dll("Add") if success: self.log("\n✓ Settings fix completed successfully!", "success") self.log("Affinity v3 should now be able to save settings properly.", "info") self.log("You may need to restart Affinity for the changes to take effect.", "info") self.show_message( "Settings Fix Complete", "The Affinity v3 DLL has been patched successfully!\n\n" "Settings should now save properly.\n" "You may need to restart Affinity for the changes to take effect.", "info" ) else: self.log("\n✗ Settings fix failed", "error") self.show_message( "Settings Fix Failed", "Failed to patch the Affinity v3 DLL.\n\n" "Please check the log for details.\n" "Make sure .NET SDK is installed if you see related errors.", "error" ) except Exception as e: self.log(f"Unexpected error during settings fix: {e}", "error") self.show_message( "Unexpected Error", f"An unexpected error occurred:\n{e}\n\n" "Please check the log for details.", "error" ) finally: self.end_operation() def set_dpi_scaling(self): """Set DPI scaling for Affinity applications""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("DPI Scaling Configuration", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" if not wine.exists(): self.log("Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") self.show_message("Wine Not Found", "Wine is not set up yet. Please run 'Setup Wine Environment' first.", "error") return # Try to get current DPI value from registry env = os.environ.copy() env["WINEPREFIX"] = self.directory current_dpi = 96 # Default value # Try to read current DPI from registry try: success, stdout, _ = self.run_command( [str(wine), "reg", "query", "HKEY_CURRENT_USER\\Control Panel\\Desktop", "/v", "LogPixels"], check=False, env=env, capture=True ) if success and stdout: # Parse the output to extract DPI value # Output format: "LogPixels REG_DWORD 0x000000c0 (192)" match = re.search(r'0x[0-9a-fA-F]+|(\d+)', stdout) if match: # Try to find hex value first hex_match = re.search(r'0x([0-9a-fA-F]+)', stdout) if hex_match: current_dpi = int(hex_match.group(1), 16) else: # Try decimal dec_match = re.search(r'\((\d+)\)', stdout) if dec_match: current_dpi = int(dec_match.group(1)) except: pass # Use default if reading fails # Create dialog dialog = QDialog(self) dialog.setWindowTitle("Set DPI Scaling") dialog.setMinimumWidth(400) layout = QVBoxLayout(dialog) # Info label info_label = QLabel( "Adjust DPI scaling for Affinity applications.\n" "Higher values make UI elements larger.\n\n" "Common values:\n" "• 96 = 100% (1080p, 24-27 inches)\n" "• 120 = 125% (1080p, 13-15 inch laptops)\n" "• 144 = 150% (1440p, 27-32 inches)\n" "• 192 = 200% (4K, 27-32 inches)" ) info_label.setWordWrap(True) layout.addWidget(info_label) # Current value display value_label = QLabel() value_label.setStyleSheet("font-size: 14px; font-weight: bold; padding: 10px;") layout.addWidget(value_label) # Slider slider = QSlider(Qt.Orientation.Horizontal) slider.setMinimum(96) slider.setMaximum(480) slider.setValue(current_dpi) slider.setTickPosition(QSlider.TickPosition.TicksBelow) slider.setTickInterval(24) # Show ticks every 24 DPI slider.setSingleStep(12) # Step by 12 DPI for smoother adjustment layout.addWidget(slider) # Min/Max labels minmax_layout = QHBoxLayout() minmax_layout.addWidget(QLabel("96 (100%)")) minmax_layout.addStretch() minmax_layout.addWidget(QLabel("480 (500%)")) layout.addLayout(minmax_layout) # Update label when slider changes def update_label(value): percentage = int((value / 96) * 100) value_label.setText(f"DPI: {value} ({percentage}%)") slider.valueChanged.connect(update_label) update_label(current_dpi) # Set initial value # Buttons buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel) buttons.button(QDialogButtonBox.StandardButton.Save).setText("Save") buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() != QDialog.DialogCode.Accepted: self.log("DPI scaling configuration cancelled", "warning") return selected_dpi = slider.value() percentage = int((selected_dpi / 96) * 100) # Apply DPI setting via registry self.log(f"Setting DPI scaling to {selected_dpi} ({percentage}%)...", "info") # Use wine reg add command success, stdout, stderr = self.run_command( [ str(wine), "reg", "add", "HKEY_CURRENT_USER\\Control Panel\\Desktop", "/v", "LogPixels", "/t", "REG_DWORD", "/d", str(selected_dpi), "/f" ], check=False, env=env ) if success: self.log(f"✓ DPI scaling set to {selected_dpi} ({percentage}%)", "success") self.log("Note: You may need to restart Affinity applications for the change to take effect.", "info") self.show_message( "DPI Scaling Updated", f"DPI scaling has been set to {selected_dpi} ({percentage}%).\n\n" "You may need to restart Affinity applications for the change to take effect.", "info" ) else: self.log(f"✗ Failed to set DPI scaling: {stderr or 'Unknown error'}", "error") self.show_message( "Error", f"Failed to set DPI scaling:\n{stderr or 'Unknown error'}", "error" ) def uninstall_affinity_linux(self): """Uninstall Affinity Linux by deleting the .AffinityLinux folder""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Uninstall Affinity Linux", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Show warning dialog with Yes/No buttons reply = QMessageBox.warning( self, "Uninstall Affinity Linux", "WARNING: This will permanently delete the .AffinityLinux folder and all its contents.\n\n" "This includes:\n" "• All Wine configuration and settings\n" "• All installed Affinity applications (Photo, Designer, Publisher, Unified)\n" "• All application data and preferences\n" "• All downloaded installers and cached files\n" "• WebView2 Runtime and other dependencies\n\n" "This action CANNOT be undone!\n\n" "Do you want to proceed with the uninstall?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply != QMessageBox.StandardButton.Yes: self.log("Uninstall cancelled by user", "warning") return # Stop Wine processes first self.log("Stopping Wine processes...", "info") try: self.run_command(["wineserver", "-k"], check=False) time.sleep(2) self.log("Wine processes stopped", "success") except Exception as e: self.log(f"Warning: Could not stop all Wine processes: {e}", "warning") # Delete the .AffinityLinux folder affinity_dir = Path(self.directory) if not affinity_dir.exists(): self.log("Affinity Linux directory not found. Nothing to uninstall.", "warning") self.show_message( "Nothing to Uninstall", "The .AffinityLinux folder does not exist.\n\nNothing to uninstall.", "info" ) return self.log(f"Deleting directory: {affinity_dir}", "info") try: shutil.rmtree(affinity_dir) self.log("✓ .AffinityLinux folder deleted successfully", "success") self.log("\n✓ Uninstall completed!", "success") self.log("All Affinity Linux files have been removed.", "info") self.show_message( "Uninstall Complete", "The .AffinityLinux folder has been successfully deleted.\n\n" "All Affinity installations and configurations have been removed.\n\n" "You may close this installer now.", "info" ) # Refresh installation status QTimer.singleShot(100, self.check_installation_status) except PermissionError: self.log("✗ Permission denied. Some files may be in use.", "error") self.log("Please close all Affinity applications and try again.", "error") self.show_message( "Uninstall Failed", "Permission denied. Some files may be in use.\n\n" "Please close all Affinity applications and Wine processes, then try again.", "error" ) except Exception as e: self.log(f"✗ Failed to delete directory: {e}", "error") self.show_message( "Uninstall Failed", f"Failed to delete the .AffinityLinux folder:\n\n{str(e)}\n\n" "You may need to manually delete it.", "error" ) def launch_affinity_v3(self): """Launch Affinity v3 with optimized environment variables""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Launch Affinity v3", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Check if Affinity is installed affinity_exe = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / "Affinity" / "Affinity.exe" if not affinity_exe.exists(): self.log("✗ Affinity v3 is not installed", "error") self.log("Please install Affinity v3 first using 'Update Affinity Applications' → 'Affinity (Unified)'", "info") self.show_message( "Affinity Not Found", "Affinity v3 is not installed.\n\nPlease install it first using:\n'Update Affinity Applications' → 'Affinity (Unified)'", QMessageBox.Icon.Warning ) return # Check if Wine is set up # Try both possible directory names runner_path = Path(self.directory) / "ElementalWarriorWine-x86_64" if not runner_path.exists(): runner_path = Path(self.directory) / "ElementalWarriorWine" wine_bin = runner_path / "bin" / "wine" if not wine_bin.exists(): self.log("✗ Wine is not set up", "error") self.log("Please run 'Setup Wine Environment' first", "info") self.show_message( "Wine Not Found", "Wine is not set up.\n\nPlease run 'Setup Wine Environment' first.", QMessageBox.Icon.Warning ) return self.log("Setting up environment variables...", "info") # Prepare environment variables env = os.environ.copy() # Set PATH to include Wine binaries runner_path_str = str(runner_path) current_path = env.get("PATH", "") env["PATH"] = f"{runner_path_str}/bin:{current_path}" # Set Wine-related environment variables env["WINE"] = str(wine_bin) env["WINEPREFIX"] = self.directory env["WINEDEBUG"] = "-all,fixme-all" env["WINEDLLOVERRIDES"] = "opencl=" # Add GPU selection environment variables if configured gpu_env = self.get_gpu_env_vars() if gpu_env: # Parse GPU env vars and add to environment for env_var in gpu_env.strip().split(): if "=" in env_var: key, value = env_var.split("=", 1) env[key] = value # Check renderer setting - only set DXVK/VKD3D if Vulkan is selected renderer = self.get_renderer_setting() if renderer == "vulkan": # DXVK settings (only for Vulkan renderer) env["DXVK_ASYNC"] = "0" env["DXVK_CONFIG"] = "d3d9.deferSurfaceCreation = True; d3d9.shaderModel = 1" env["DXVK_FRAME_RATE"] = "60" env["DXVK_LOG_LEVEL"] = "none" # VKD3D settings (only for Vulkan renderer) env["VKD3D_DEBUG"] = "none" env["VKD3D_DISABLE_EXTENSIONS"] = "VK_KHR_present_id" env["VKD3D_FEATURE_LEVEL"] = "12_1" env["VKD3D_FRAME_RATE"] = "60" env["VKD3D_SHADER_DEBUG"] = "none" env["VKD3D_SHADER_MODEL"] = "6_5" else: # For OpenGL or GDI, disable DXVK/VKD3D to prevent Vulkan initialization errors # Also disable DLL overrides that might force Vulkan env["DXVK_STATE_CACHE"] = "0" env["DXVK_HUD"] = "0" # Don't set VKD3D variables for OpenGL/GDI self.log(f"Renderer is set to {renderer.upper()}, DXVK/VKD3D disabled", "info") # If OpenGL/GDI is selected and OpenCL is disabled, remove d3d12 DLL overrides # that might force Vulkan usage if not self.is_opencl_enabled(): self.log("Removing d3d12 DLL overrides to prevent Vulkan initialization", "info") try: wine = Path(self.directory) / "ElementalWarriorWine" / "bin" / "wine" reg_env = os.environ.copy() reg_env["WINEPREFIX"] = self.directory # Remove d3d12 and d3d12core overrides self.run_command([str(wine), "reg", "delete", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", "d3d12", "/f"], check=False, env=reg_env, capture=True) self.run_command([str(wine), "reg", "delete", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", "d3d12core", "/f"], check=False, env=reg_env, capture=True) except Exception as e: self.log(f"Warning: Could not remove d3d12 DLL overrides: {e}", "warning") self.log("✓ Environment variables configured", "success") self.log(f"Wine: {wine_bin}", "info") self.log(f"WINEPREFIX: {self.directory}", "info") self.log(f"Affinity: {affinity_exe}", "info") # Launch Affinity using wine start self.log("\nLaunching Affinity v3...", "info") # Use wine start to launch the application wine_start_cmd = [ str(wine_bin), "start", "C:/Program Files/Affinity/Affinity/Affinity.exe" ] try: # Launch in background (non-blocking) process = subprocess.Popen( wine_start_cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True ) self.log("✓ Affinity v3 launched successfully", "success") self.log("The application should open in a moment...", "info") except Exception as e: self.log(f"✗ Failed to launch Affinity v3: {e}", "error") self.show_message( "Launch Failed", f"Failed to launch Affinity v3:\n\n{str(e)}", QMessageBox.Icon.Critical ) def download_affinity_installer(self): """Download the Affinity installer by itself""" self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") self.log("Download Affinity Installer", "info") self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") # Ask user where to save the file downloads_dir = Path.home() / "Downloads" default_path = downloads_dir / "Affinity-x64.exe" # Suggest Downloads folder by default, but let user choose suggested_path = str(default_path) save_path, _ = QFileDialog.getSaveFileName( self, "Save Affinity Installer", suggested_path, "Executable files (*.exe);;All files (*.*)" ) if not save_path: self.log("Download cancelled.", "warning") return save_path_obj = Path(save_path) # Start operation and thread to download self.start_operation("Download Affinity Installer") threading.Thread(target=self._download_affinity_installer_thread, args=(save_path_obj,), daemon=True).start() # The rest of the logic should be in the _download_affinity_installer_thread method # This is just a placeholder to fix the syntax error pass def show_thanks(self): """Show special thanks window""" thanks = QMessageBox(self) thanks.setWindowTitle("Special Thanks") thanks.setText("Special Thanks\n\n" "Ardishco (github.com/raidenovich)\n" "Deviaze\n" "Kemal\n" "Jacazimbo <3\n" "Kharoon\n" "Jediclank134") thanks.setStandardButtons(QMessageBox.StandardButton.Ok) thanks.exec() def main(): """Main entry point""" if platform.system() != "Linux": app = QApplication(sys.argv) QMessageBox.critical( None, "Unsupported Platform", "This installer is designed for Linux systems only." ) return app = QApplication(sys.argv) window = AffinityInstallerGUI() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()