#!/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
import json
import tempfile
from pathlib import Path
import time
import signal
import shlex
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()
if distro == "pika":
distro = "pikaos"
return distro
except (IOError, FileNotFoundError):
pass
return None
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}...")
distro = detect_distro_for_install()
pip_flags = ["--user"]
if distro in ["arch", "cachyos", "manjaro", "endeavouros", "xerolinux"]:
pip_flags.append("--break-system-packages")
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
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, QSizePolicy
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
from PyQt6.QtGui import QFont, QColor, QPalette, QIcon, QPixmap, QShortcut, QKeySequence, QWheelEvent, QPainter, QPen
# Try to import SVG widget (may not be available on all distributions)
try:
from PyQt6.QtSvgWidgets import QSvgWidget
SVG_WIDGET_AVAILABLE = True
except ImportError:
print("⚠️ QSvgWidget not available - some icons may not display correctly")
SVG_WIDGET_AVAILABLE = False
# Create a dummy QSvgWidget class to prevent import errors
class QSvgWidget(QWidget):
def load(self, content): pass
def setFixedSize(self, size): super().setFixedSize(size)
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, QSizePolicy
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QSize, QTimer
from PyQt6.QtGui import QFont, QColor, QPalette, QIcon, QPixmap, QShortcut, QKeySequence, QWheelEvent, QPainter, QPen
# Try to import SVG widget after installation
try:
from PyQt6.QtSvgWidgets import QSvgWidget
SVG_WIDGET_AVAILABLE = True
except ImportError:
print("⚠️ QSvgWidget not available after installation - some icons may not display correctly")
SVG_WIDGET_AVAILABLE = False
# Create a dummy QSvgWidget class to prevent import errors
class QSvgWidget(QWidget):
def load(self, content): pass
def setFixedSize(self, size): super().setFixedSize(size)
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:
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:
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):
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)
start_angle = int(self._angle * 16)
span_angle = int(270 * 16)
painter.drawArc(rect, start_angle, span_angle)
painter.end()
class AffinityInstallerGUI(QMainWindow):
log_signal = pyqtSignal(str, str)
progress_signal = pyqtSignal(float)
progress_text_signal = pyqtSignal(str)
show_message_signal = pyqtSignal(str, str, str)
sudo_password_dialog_signal = pyqtSignal()
interactive_prompt_signal = pyqtSignal(str, str)
question_dialog_signal = pyqtSignal(str, str, list)
nvidia_dxvk_vkd3d_choice_signal = pyqtSignal()
prompt_affinity_install_signal = pyqtSignal()
install_application_signal = pyqtSignal(str)
show_spinner_signal = pyqtSignal(object)
hide_spinner_signal = pyqtSignal(object)
def __init__(self):
startup_start = time.time()
timing_log = []
def log_timing(step_name, start_time):
elapsed = time.time() - start_time
timing_log.append((step_name, elapsed))
return time.time()
step_start = time.time()
super().__init__()
step_start = log_timing("QMainWindow.__init__", step_start)
self.setWindowTitle("Affinity Linux Installer")
screen = self.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
min_width = max(640, int(screen_width * 0.7))
min_height = max(480, int(screen_height * 0.7))
self.setMinimumSize(min_width, min_height)
default_width = min(1200, int(screen_width * 0.8))
default_height = min(900, int(screen_height * 0.8))
self.resize(default_width, default_height)
step_start = log_timing("Window setup", step_start)
self.distro = None
self.distro_version = None
self.directory = str(Path.home() / ".AffinityLinux")
self.setup_complete = False
self.installer_file = None
self.update_buttons = {}
self.switch_backend_button = None
self.log_font_size = 11
self.operation_cancelled = False
self.current_operation = None
self.operation_in_progress = False
self.sudo_password = None
self.sudo_password_validated = False
self.interactive_response = None
self.waiting_for_response = False
self.question_dialog_response = None
self.waiting_for_question_response = False
self.nvidia_dxvk_vkd3d_choice_response = None
self.waiting_for_nvidia_choice = False
self.dark_mode = True
self.icon_buttons = []
self.enable_opencl = False
self.cancel_event = threading.Event()
self._process_lock = threading.Lock()
self._active_processes = set()
self._button_spinner_map = {}
self._last_clicked_button = None
self._operation_button = None
self.log_file_path = Path.home() / "AffinitySetup.log"
self.log_file = None
self._init_log_file()
step_start = log_timing("Log file init", step_start)
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.nvidia_dxvk_vkd3d_choice_signal.connect(self._show_nvidia_dxvk_vkd3d_choice_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)
step_start = log_timing("Signal connections", step_start)
self.create_ui()
step_start = log_timing("Create UI", step_start)
self.apply_theme()
step_start = log_timing("Apply theme", step_start)
self.center_window()
step_start = log_timing("Center window", step_start)
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Affinity Linux Installer - Ready", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
step_start = log_timing("Defer slow operations", step_start)
total_time = time.time() - startup_start
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
self.log("Startup Performance:", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
for step_name, elapsed in timing_log:
percentage = (elapsed / total_time * 100) if total_time > 0 else 0
self.log(f" {step_name:.<30} {elapsed:>6.3f}s ({percentage:>5.1f}%)", "info")
self.log(f" {'TOTAL STARTUP TIME':.<30} {total_time:>6.3f}s", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
self.log("Welcome! Please use the buttons on the right to get started.", "info")
system_specs = self._get_system_specs()
if system_specs:
self.log("", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
self.log("System Specifications:", "info")
for spec in system_specs:
self.log(f" {spec}", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
if self.log_file:
try:
self.log_file.write(f"\nSystem Specifications:\n")
for spec in system_specs:
self.log_file.write(f" {spec}\n")
self.log_file.write(f"{'='*80}\n")
self.log_file.flush()
except Exception:
pass
from PyQt6.QtCore import QTimer
QTimer.singleShot(50, self._deferred_startup_tasks)
QTimer.singleShot(500, self._check_and_update_dxvk_vkd3d)
def _deferred_startup_tasks(self):
"""Run slow startup tasks in background after window is shown"""
self.load_affinity_icon()
self.setup_zoom()
def run_background_tasks():
import time as time_module
bg_start = time_module.time()
icons_start = time_module.time()
self._ensure_icons_directory()
icons_time = time_module.time() - icons_start
if icons_time > 0.1:
QTimer.singleShot(100, self._update_button_icons)
patcher_start = time_module.time()
self.ensure_patcher_files(silent=True)
patcher_time = time_module.time() - patcher_start
status_start = time_module.time()
self.check_installation_status()
status_time = time_module.time() - status_start
total_bg_time = time_module.time() - bg_start
if icons_time > 0.1 or patcher_time > 0.1 or status_time > 0.1:
self.log(f"Background tasks completed: icons={icons_time:.3f}s, patcher={patcher_time:.3f}s, status={status_time:.3f}s, total={total_bg_time:.3f}s", "info")
wine_path = self.get_wine_path("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")
threading.Thread(target=run_background_tasks, daemon=True).start()
def check_installation_status(self):
self.update_switch_backend_button()
"""Check if Wine and Affinity applications are installed, and update button states"""
wine = self.get_wine_path("wine")
wine_staging = self.get_wine_path("wine-staging")
# Check if either wine or wine-staging exists
wine_exists = wine.exists() or wine_staging.exists()
wine_version_display = "Wine"
if wine_exists:
# Try both wine and wine-staging binaries
for wine_bin in [wine, wine_staging]:
if wine_bin.exists():
try:
success, stdout, _ = self.run_command([str(wine_bin), "--version"], check=False, capture=True)
if success and stdout:
version_match = re.search(r'wine-(\d+\.\d+)', stdout)
if version_match:
wine_version_display = f"Wine {version_match.group(1)}"
break # Found a working wine binary, no need to check further
else:
wine_dir = Path(self.directory) / "ElementalWarriorWine"
if (wine_dir / "bin" / "wine").exists():
wine_version_display = "Wine (patched)"
break
except Exception:
continue
# If we still haven't found a version, mark as patched
if wine_version_display == "Wine":
wine_version_display = "Wine (patched)"
if hasattr(self, 'system_status_label'):
if wine_exists:
self.system_status_label.setStyleSheet("font-size: 12px; color: #4ec9b0; background-color: transparent; border: none; padding: 0px;")
self.system_status_label.setToolTip(f"System Status: Ready - {wine_version_display} is installed")
if hasattr(self, 'status_text_label'):
self.status_text_label.setText("Ready")
else:
self.system_status_label.setStyleSheet("font-size: 12px; color: #f48771; background-color: transparent; border: none; padding: 0px;")
self.system_status_label.setToolTip("System Status: Not Ready - Wine needs to be installed")
if hasattr(self, 'status_text_label'):
self.status_text_label.setText("Not Ready")
if wine_exists:
self.log(f"Wine: ✓ Installed ({wine_version_display})", "success")
else:
self.log("Wine: ✗ Not installed", "error")
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")
if app_name in self.update_buttons:
btn = self.update_buttons[app_name]
if is_installed:
current_text = btn.text()
if "✓" not in current_text:
btn.setText(current_text.split("✓")[0].strip() + " ✓")
btn.setEnabled(True)
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
if self.check_command("unzstd") or self.check_command("zstd"):
self.log(f" zstd: ✓ Installed", "success")
else:
self.log(f" zstd: ✗ Not installed (optional)", "warning")
if self.check_command("xz") or self.check_command("unxz"):
self.log(f" xz: ✓ Installed", "success")
else:
self.log(f" xz: ✗ Not installed (optional - Python lzma will be used)", "warning")
if self.check_dotnet_sdk():
self.log(f" .NET SDK: ✓ Installed", "success")
else:
self.log(f" .NET SDK: ✗ Not installed", "error")
if wine_exists:
self.log("Winetricks Dependencies:", "info")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
wine = self.get_wine_path("wine")
winetricks_components = [
("dotnet35sp1", ".NET Framework 3.5 SP1"),
("dotnet48", ".NET Framework 4.8"),
("corefonts", "Windows Core Fonts"),
("vcrun2022", "Visual C++ Redistributables 2022"),
("msxml3", "MSXML 3.0"),
("msxml6", "MSXML 6.0"),
("crypt32", "Cryptographic API 32"),
]
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")
try:
success, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\Direct3D"],
check=False,
env=env,
capture=True
)
if success:
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")
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")
for app_name, button in self.update_buttons.items():
if button is None:
continue
is_installed = app_status.get(app_name, False)
enabled = wine_exists and is_installed
button.setEnabled(enabled)
if enabled:
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_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_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)
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)
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)
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)
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
theme_suffix = "light" if self.dark_mode else "dark"
icons_dir = Path.home() / ".config" / "AffinityOnLinux" / "AffinityScripts" / "icons"
themed_icon_path = icons_dir / f"{icon_name}-{theme_suffix}.svg"
if themed_icon_path.exists():
return themed_icon_path
base_icon_path = icons_dir / f"{icon_name}.svg"
if base_icon_path.exists():
return base_icon_path
local_icons_dir = Path(__file__).parent / "icons"
if local_icons_dir.exists():
local_themed_icon = local_icons_dir / f"{icon_name}-{theme_suffix}.svg"
if local_themed_icon.exists():
return local_themed_icon
local_base_icon = local_icons_dir / f"{icon_name}.svg"
if local_base_icon.exists():
return local_base_icon
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()
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")
self._update_button_icons()
self._update_top_bar_style()
self._update_theme_button_style()
self._update_right_scroll_style()
self._update_progress_label_style()
def get_dialog_stylesheet(self):
"""Get the appropriate stylesheet for dialogs based on current theme - clean modern style"""
if self.dark_mode:
return """
QDialog {
background-color: #252526;
color: #dcdcdc;
}
QLabel {
color: #dcdcdc;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4ec9b0;
padding: 10px 0px;
background-color: transparent;
border: none;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #cccccc;
padding: 5px 0px 15px 0px;
line-height: 1.4;
background-color: transparent;
border: none;
}
QLineEdit {
background-color: #3c3c3c;
color: #dcdcdc;
border: 1px solid #555555;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
}
QLineEdit:focus {
border: 1px solid #4ec9b0;
background-color: #3d3d3d;
}
QFrame#optionFrame {
background-color: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #4a4a4a;
background-color: #323232;
}
QRadioButton {
font-size: 16px;
color: #dcdcdc;
padding: 8px 0px;
spacing: 10px;
font-weight: 500;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #555555;
background-color: #3c3c3c;
}
QRadioButton::indicator:hover {
border-color: #6a6a6a;
}
QRadioButton::indicator:checked {
background-color: #4ec9b0;
border-color: #4ec9b0;
}
QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QPushButton:pressed {
background-color: #2d2d2d;
}
QPushButton#okButton, QPushButton#primaryButton {
background-color: #4ec9b0;
color: #1e1e1e;
border: 1px solid #4ec9b0;
font-weight: bold;
}
QPushButton#okButton:hover, QPushButton#primaryButton:hover {
background-color: #5dd9c0;
border-color: #5dd9c0;
}
QPushButton#okButton:pressed, QPushButton#primaryButton:pressed {
background-color: #3db9a0;
}
QDialogButtonBox QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QDialogButtonBox QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QDialogButtonBox QPushButton:pressed {
background-color: #2d2d2d;
}
QSlider::groove:horizontal {
background-color: #3c3c3c;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #4ec9b0;
width: 18px;
height: 18px;
margin: -6px 0;
border-radius: 9px;
}
QSlider::handle:horizontal:hover {
background-color: #5dd9c0;
}
QSlider::sub-page:horizontal {
background-color: #4ec9b0;
border-radius: 3px;
}
QSlider::add-page:horizontal {
background-color: #3c3c3c;
border-radius: 3px;
}
QScrollArea {
border: none;
background-color: transparent;
}
QScrollBar:vertical {
background-color: #2d2d2d;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555555;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #666666;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
"""
else:
return """
QDialog {
background-color: #ffffff;
color: #2d2d2d;
}
QLabel {
color: #2d2d2d;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4caf50;
padding: 10px 0px;
background-color: transparent;
border: none;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #555555;
padding: 5px 0px 15px 0px;
line-height: 1.4;
background-color: transparent;
border: none;
}
QLineEdit {
background-color: #ffffff;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 6px;
padding: 8px 12px;
font-size: 13px;
}
QLineEdit:focus {
border: 1px solid #4caf50;
background-color: #fafafa;
}
QFrame#optionFrame {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #c0c0c0;
background-color: #fafafa;
}
QRadioButton {
font-size: 14px;
color: #2d2d2d;
padding: 8px 0px;
spacing: 10px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #c0c0c0;
background-color: #ffffff;
}
QRadioButton::indicator:hover {
border-color: #a0a0a0;
}
QRadioButton::indicator:checked {
background-color: #4caf50;
border-color: #4caf50;
}
QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QPushButton:pressed {
background-color: #c0c0c0;
}
QPushButton#okButton, QPushButton#primaryButton {
background-color: #4caf50;
color: #ffffff;
border: 1px solid #4caf50;
font-weight: bold;
}
QPushButton#okButton:hover, QPushButton#primaryButton:hover {
background-color: #45a049;
border-color: #45a049;
}
QPushButton#okButton:pressed, QPushButton#primaryButton:pressed {
background-color: #3d8b40;
}
QDialogButtonBox QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QDialogButtonBox QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QDialogButtonBox QPushButton:pressed {
background-color: #c0c0c0;
}
QSlider::groove:horizontal {
background-color: #e0e0e0;
height: 6px;
border-radius: 3px;
}
QSlider::handle:horizontal {
background-color: #4caf50;
width: 18px;
height: 18px;
margin: -6px 0;
border-radius: 9px;
}
QSlider::handle:horizontal:hover {
background-color: #45a049;
}
QSlider::sub-page:horizontal {
background-color: #4caf50;
border-radius: 3px;
}
QSlider::add-page:horizontal {
background-color: #e0e0e0;
border-radius: 3px;
}
QScrollArea {
border: none;
background-color: transparent;
}
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;
}
"""
def get_messagebox_stylesheet(self):
"""Get the appropriate stylesheet for message boxes based on current theme - clean modern style"""
if self.dark_mode:
return """
QMessageBox {
background-color: #252526;
color: #dcdcdc;
}
QMessageBox QLabel {
color: #dcdcdc;
background-color: transparent;
font-size: 13px;
line-height: 1.4;
}
QMessageBox QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QMessageBox QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QMessageBox QPushButton:pressed {
background-color: #2d2d2d;
}
QMessageBox QPushButton[default="true"] {
background-color: #4ec9b0;
color: #1e1e1e;
border: 1px solid #4ec9b0;
font-weight: bold;
}
QMessageBox QPushButton[default="true"]:hover {
background-color: #5dd9c0;
border-color: #5dd9c0;
}
QMessageBox QPushButton[default="true"]:pressed {
background-color: #3db9a0;
}
"""
else:
return """
QMessageBox {
background-color: #ffffff;
color: #2d2d2d;
}
QMessageBox QLabel {
color: #2d2d2d;
background-color: transparent;
font-size: 13px;
line-height: 1.4;
}
QMessageBox QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QMessageBox QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QMessageBox QPushButton:pressed {
background-color: #c0c0c0;
}
QMessageBox QPushButton[default="true"] {
background-color: #4caf50;
color: #ffffff;
border: 1px solid #4caf50;
font-weight: bold;
}
QMessageBox QPushButton[default="true"]:hover {
background-color: #45a049;
border-color: #45a049;
}
QMessageBox QPushButton[default="true"]:pressed {
background-color: #3d8b40;
}
"""
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 with card-based design"""
self.setStyleSheet("""
QMainWindow {
background-color: #1a1a1a;
}
QWidget {
background-color: #1a1a1a;
color: #e0e0e0;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
}
/* Top Bar */
QFrame#topBar {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #2a2a2a, stop:1 #1f1f1f);
border-bottom: 2px solid #333333;
}
QLabel#titleLabel {
font-size: 20px;
font-weight: 600;
color: #ffffff;
letter-spacing: -0.5px;
background-color: transparent;
border: none;
padding: 0px;
}
QLabel#statusIndicator {
font-size: 12px;
color: #666666;
background-color: transparent;
border: none;
padding: 0px;
}
QLabel#statusText {
font-size: 12px;
color: #999999;
font-weight: 500;
background-color: transparent;
border: none;
padding: 0px;
}
QPushButton#themeToggle {
background-color: #333333;
color: #e0e0e0;
border: 1px solid #444444;
border-radius: 8px;
font-size: 18px;
}
QPushButton#themeToggle:hover {
background-color: #3d3d3d;
border-color: #555555;
}
/* Content Area */
QWidget#contentArea {
background-color: #1a1a1a;
}
/* Status Card */
QFrame#statusCard {
background-color: #252525;
border: 1px solid #333333;
border-radius: 12px;
}
QLabel#sectionTitle {
font-size: 16px;
font-weight: 600;
color: #ffffff;
background-color: transparent;
border: none;
padding: 0px;
}
/* Progress Section */
QFrame#progressSection {
background-color: #1e1e1e;
border: 1px solid #2d2d2d;
border-radius: 8px;
padding: 12px;
}
QLabel#progressLabel {
font-size: 12px;
font-weight: 500;
color: #b0b0b0;
padding: 8px 12px;
background-color: transparent;
border: none;
border-radius: 0px;
}
QProgressBar#progressBar {
border: none;
background-color: #1a1a1a;
height: 8px;
border-radius: 4px;
}
QProgressBar#progressBar::chunk {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #4ec9b0, stop:1 #5dd9c0);
border-radius: 4px;
}
QPushButton#cancelButton {
background-color: #d32f2f;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
}
QPushButton#cancelButton:hover {
background-color: #e53935;
}
QPushButton#cancelButton:pressed {
background-color: #b71c1c;
}
/* Log Section */
QFrame#logSection {
background-color: #1e1e1e;
border: 1px solid #2d2d2d;
border-radius: 8px;
padding: 12px;
}
QFrame#zoomToolbar {
background-color: transparent;
border: none;
}
QPushButton#zoomButton {
background-color: #2d2d2d;
color: #b0b0b0;
border: 1px solid #3d3d3d;
border-radius: 6px;
}
QPushButton#zoomButton:hover {
background-color: #3a3a3a;
border-color: #4a4a4a;
color: #ffffff;
}
QPushButton#zoomButton:disabled {
background-color: #252525;
color: #555555;
border-color: #2d2d2d;
}
QTextEdit#logText {
background-color: #0d0d0d;
color: #d4d4d4;
border: 1px solid #2d2d2d;
border-radius: 8px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
padding: 12px;
selection-background-color: #007acc;
}
/* Button Cards */
QFrame#buttonCard {
background-color: #252525;
border: 1px solid #333333;
border-radius: 12px;
}
QPushButton#actionButton {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #3d3d3d;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
text-align: left;
min-width: 200px;
}
QPushButton#actionButton:hover {
background-color: #353535;
border-color: #4d4d4d;
color: #ffffff;
}
QPushButton#actionButton:pressed {
background-color: #252525;
border-color: #3d3d3d;
}
QPushButton#actionButton:disabled {
background-color: #1f1f1f;
color: #555555;
border-color: #2d2d2d;
}
QPushButton#actionButton[class="primary"] {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #4ec9b0, stop:1 #3db9a0);
color: #000000;
font-weight: 600;
font-size: 14px;
border: none;
}
QPushButton#actionButton[class="primary"]:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #5dd9c0, stop:1 #4ec9b0);
}
QPushButton#actionButton[class="primary"]:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #3db9a0, stop:1 #2da990);
}
/* Scroll Area */
QScrollArea#rightScroll {
background-color: #1a1a1a;
border: none;
}
QScrollBar:vertical {
background-color: #1a1a1a;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #3d3d3d;
border-radius: 5px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #4d4d4d;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QToolTip {
background-color: #2d2d2d;
color: #e0e0e0;
border: 1px solid #444444;
padding: 6px;
border-radius: 6px;
font-size: 11px;
}
QDialog {
background-color: #252525;
border-radius: 12px;
}
QDialog QLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QDialog QLabel#titleLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QDialog QLabel#descriptionLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QMessageBox {
background-color: #252525;
border-radius: 12px;
}
QMessageBox QLabel {
background-color: transparent;
border: none;
padding: 0px;
}
""")
def _apply_light_theme(self):
"""Apply modern light theme with card-based design"""
self.setStyleSheet("""
QMainWindow {
background-color: #f5f5f7;
}
QWidget {
background-color: #f5f5f7;
color: #1d1d1f;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
font-size: 13px;
}
/* Top Bar */
QFrame#topBar {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #ffffff, stop:1 #f5f5f7);
border-bottom: 2px solid #e0e0e0;
}
QLabel#titleLabel {
font-size: 20px;
font-weight: 600;
color: #1d1d1f;
letter-spacing: -0.5px;
background-color: transparent;
border: none;
padding: 0px;
}
QLabel#statusIndicator {
font-size: 12px;
color: #86868b;
background-color: transparent;
border: none;
padding: 0px;
}
QLabel#statusText {
font-size: 12px;
color: #515154;
font-weight: 500;
background-color: transparent;
border: none;
padding: 0px;
}
QPushButton#themeToggle {
background-color: #e5e5e7;
color: #1d1d1f;
border: 1px solid #d0d0d0;
border-radius: 8px;
font-size: 18px;
}
QPushButton#themeToggle:hover {
background-color: #d5d5d7;
border-color: #c0c0c0;
}
/* Content Area */
QWidget#contentArea {
background-color: #f5f5f7;
}
/* Status Card */
QFrame#statusCard {
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
}
QLabel#sectionTitle {
font-size: 16px;
font-weight: 600;
color: #1d1d1f;
background-color: transparent;
border: none;
padding: 0px;
}
/* Progress Section */
QFrame#progressSection {
background-color: #fafafa;
border: 1px solid #e5e5e7;
border-radius: 8px;
padding: 12px;
}
QLabel#progressLabel {
font-size: 12px;
font-weight: 500;
color: #515154;
padding: 8px 12px;
background-color: transparent;
border: none;
border-radius: 0px;
}
QProgressBar#progressBar {
border: none;
background-color: #e5e5e7;
height: 8px;
border-radius: 4px;
}
QProgressBar#progressBar::chunk {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 #34c759, stop:1 #30d158);
border-radius: 4px;
}
QPushButton#cancelButton {
background-color: #ff3b30;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
}
QPushButton#cancelButton:hover {
background-color: #ff453a;
}
QPushButton#cancelButton:pressed {
background-color: #d70015;
}
/* Log Section */
QFrame#logSection {
background-color: #fafafa;
border: 1px solid #e5e5e7;
border-radius: 8px;
padding: 12px;
}
QFrame#zoomToolbar {
background-color: transparent;
border: none;
}
QPushButton#zoomButton {
background-color: #ffffff;
color: #515154;
border: 1px solid #d0d0d0;
border-radius: 6px;
}
QPushButton#zoomButton:hover {
background-color: #f5f5f7;
border-color: #c0c0c0;
color: #1d1d1f;
}
QPushButton#zoomButton:disabled {
background-color: #f5f5f7;
color: #86868b;
border-color: #e0e0e0;
}
QTextEdit#logText {
background-color: #ffffff;
color: #1d1d1f;
border: 1px solid #e5e5e7;
border-radius: 8px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 11px;
padding: 12px;
selection-background-color: #007aff;
}
/* Button Cards */
QFrame#buttonCard {
background-color: #ffffff;
border: 1px solid #e0e0e0;
border-radius: 12px;
}
QPushButton#actionButton {
background-color: #f5f5f7;
color: #1d1d1f;
border: 1px solid #e5e5e7;
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
text-align: left;
min-width: 200px;
}
QPushButton#actionButton:hover {
background-color: #ffffff;
border-color: #d0d0d0;
color: #000000;
}
QPushButton#actionButton:pressed {
background-color: #e5e5e7;
border-color: #c0c0c0;
}
QPushButton#actionButton:disabled {
background-color: #f5f5f7;
color: #86868b;
border-color: #e5e5e7;
}
QPushButton#actionButton[class="primary"] {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #34c759, stop:1 #30d158);
color: #ffffff;
font-weight: 600;
font-size: 14px;
border: none;
}
QPushButton#actionButton[class="primary"]:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #30d158, stop:1 #2dd45f);
}
QPushButton#actionButton[class="primary"]:pressed {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #28cd55, stop:1 #24c04f);
}
/* Scroll Area */
QScrollArea#rightScroll {
background-color: #f5f5f7;
border: none;
}
QScrollBar:vertical {
background-color: #f5f5f7;
width: 10px;
border-radius: 5px;
}
QScrollBar::handle:vertical {
background-color: #d0d0d0;
border-radius: 5px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #c0c0c0;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
QToolTip {
background-color: #1d1d1f;
color: #ffffff;
border: 1px solid #2d2d2f;
padding: 6px;
border-radius: 6px;
font-size: 11px;
}
QDialog {
background-color: #ffffff;
border-radius: 12px;
}
QDialog QLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QDialog QLabel#titleLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QDialog QLabel#descriptionLabel {
background-color: transparent;
border: none;
padding: 0px;
}
QMessageBox {
background-color: #ffffff;
border-radius: 12px;
}
QMessageBox QLabel {
background-color: transparent;
border: none;
padding: 0px;
}
""")
def _update_theme_button_style(self):
"""Update theme toggle button styling based on current theme"""
pass
def _update_top_bar_style(self):
"""Update top bar styling based on current theme"""
pass
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: transparent; border: none; border-radius: 0px;"
)
else:
self.progress_label.setStyleSheet(
"font-size: 11px; font-weight: 500; color: #2d2d2d; "
"padding: 5px 10px; background-color: transparent; border: none; border-radius: 0px;"
)
def create_ui(self):
"""Create the modern user interface"""
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setSpacing(0)
main_layout.setContentsMargins(0, 0, 0, 0)
screen = self.screen().availableGeometry()
screen_width = screen.width()
if screen_width < 1024:
top_bar_height = 56
top_bar_margin = 12
top_bar_spacing = 12
icon_size = 32
else:
top_bar_height = 64
top_bar_margin = 24
top_bar_spacing = 16
icon_size = 40
self.top_bar = QFrame()
self.top_bar.setFixedHeight(top_bar_height)
self.top_bar.setObjectName("topBar")
top_bar_layout = QHBoxLayout(self.top_bar)
top_bar_layout.setContentsMargins(top_bar_margin, 12, top_bar_margin, 12)
top_bar_layout.setSpacing(top_bar_spacing)
if hasattr(self, 'affinity_icon_path') and self.affinity_icon_path:
try:
icon = QIcon(self.affinity_icon_path)
self.setWindowIcon(icon)
try:
svg_widget = QSvgWidget(self.affinity_icon_path)
svg_widget.setFixedSize(icon_size, icon_size)
svg_widget.setStyleSheet("background: transparent;")
top_bar_layout.addWidget(svg_widget)
except Exception:
icon_label = QLabel()
pixmap = icon.pixmap(icon_size, icon_size)
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(icon_size, icon_size, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
icon_label.setFixedSize(icon_size, icon_size)
top_bar_layout.addWidget(icon_label)
except Exception:
pass
self.title_label = QLabel("Affinity on Linux")
self.title_label.setObjectName("titleLabel")
if screen_width < 1024:
self.title_label.setStyleSheet("font-size: 18px; font-weight: 600; background-color: transparent; border: none; padding: 0px;")
else:
self.title_label.setStyleSheet("background-color: transparent; border: none; padding: 0px;")
top_bar_layout.addWidget(self.title_label)
top_bar_layout.addStretch()
status_container = QWidget()
status_container.setStyleSheet("background-color: transparent; border: none;")
status_layout = QHBoxLayout(status_container)
status_layout.setContentsMargins(0, 0, 0, 0)
status_layout.setSpacing(8)
self.system_status_label = QLabel("●")
self.system_status_label.setObjectName("statusIndicator")
self.system_status_label.setToolTip("System Status: Initializing...")
status_layout.addWidget(self.system_status_label)
status_text = QLabel("Initializing...")
status_text.setObjectName("statusText")
if screen_width < 800:
status_text.setVisible(False)
status_layout.addWidget(status_text)
self.status_text_label = status_text
top_bar_layout.addWidget(status_container)
self.theme_toggle_btn = QPushButton("☀")
self.theme_toggle_btn.setObjectName("themeToggle")
self.theme_toggle_btn.setToolTip("Switch Theme")
self.theme_toggle_btn.setFixedSize(icon_size, icon_size)
self.theme_toggle_btn.clicked.connect(self.toggle_theme)
top_bar_layout.addWidget(self.theme_toggle_btn)
main_layout.addWidget(self.top_bar)
content_widget = QWidget()
content_widget.setObjectName("contentArea")
content_layout = QHBoxLayout(content_widget)
if screen_width < 1024:
content_spacing = 12
content_margin = 12
right_panel_min = 280
right_panel_max = 320
elif screen_width < 1280:
content_spacing = 16
content_margin = 16
right_panel_min = 320
right_panel_max = 380
else:
content_spacing = 20
content_margin = 20
right_panel_min = 360
right_panel_max = 420
content_layout.setSpacing(content_spacing)
content_layout.setContentsMargins(content_margin, content_margin, content_margin, content_margin)
left_panel = self.create_status_section()
content_layout.addWidget(left_panel, stretch=2)
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)
right_scroll.setObjectName("rightScroll")
self.right_scroll = right_scroll
self._update_right_scroll_style()
right_panel = self.create_button_sections()
right_scroll.setWidget(right_panel)
right_scroll.setMinimumWidth(right_panel_min)
right_scroll.setMaximumWidth(right_panel_max)
content_layout.addWidget(right_scroll, stretch=1)
main_layout.addWidget(content_widget, stretch=1)
def create_status_section(self):
"""Create the modern status/log output section (responsive)"""
screen = self.screen().availableGeometry()
screen_width = screen.width()
if screen_width < 1024:
card_spacing = 12
card_margin = 12
elif screen_width < 1280:
card_spacing = 14
card_margin = 16
else:
card_spacing = 16
card_margin = 20
card = QFrame()
card.setObjectName("statusCard")
card_layout = QVBoxLayout(card)
card_layout.setSpacing(card_spacing)
card_layout.setContentsMargins(card_margin, card_margin, card_margin, card_margin)
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
title = QLabel("Status & Log")
title.setObjectName("sectionTitle")
header.addWidget(title)
header.addStretch()
card_layout.addLayout(header)
progress_section = QFrame()
progress_section.setObjectName("progressSection")
progress_layout = QVBoxLayout(progress_section)
progress_layout.setSpacing(8)
progress_layout.setContentsMargins(0, 0, 0, 0)
self.progress_label = QLabel("Ready")
self.progress_label.setObjectName("progressLabel")
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
progress_layout.addWidget(self.progress_label)
progress_container = QHBoxLayout()
progress_container.setSpacing(12)
progress_container.setContentsMargins(0, 0, 0, 0)
self.progress = QProgressBar()
self.progress.setObjectName("progressBar")
self.progress.setRange(0, 100)
self.progress.setValue(0)
self.progress.setTextVisible(False)
self.progress.setFixedHeight(8)
progress_container.addWidget(self.progress, stretch=1)
self.cancel_btn = QPushButton("✕")
self.cancel_btn.setObjectName("cancelButton")
self.cancel_btn.setToolTip("Cancel current operation")
self.cancel_btn.setFixedSize(32, 32)
self.cancel_btn.setVisible(False)
self.cancel_btn.clicked.connect(self.cancel_operation)
progress_container.addWidget(self.cancel_btn)
progress_layout.addLayout(progress_container)
card_layout.addWidget(progress_section)
log_section = QFrame()
log_section.setObjectName("logSection")
log_layout = QVBoxLayout(log_section)
log_layout.setSpacing(12)
log_layout.setContentsMargins(0, 0, 0, 0)
zoom_toolbar = QFrame()
zoom_toolbar.setObjectName("zoomToolbar")
zoom_layout = QHBoxLayout(zoom_toolbar)
zoom_layout.setContentsMargins(0, 0, 0, 0)
zoom_layout.setSpacing(8)
zoom_layout.addStretch()
icon_name_zoom_out = "zoom-out"
icon_path_zoom_out = self.get_icon_path(icon_name_zoom_out)
self.zoom_out_btn = QPushButton()
self.zoom_out_btn.setObjectName("zoomButton")
self.zoom_out_btn.setToolTip("Zoom Out (Ctrl+-)")
self.zoom_out_btn.setFixedSize(32, 32)
if icon_path_zoom_out:
self.zoom_out_btn.setIcon(QIcon(str(icon_path_zoom_out)))
self.zoom_out_btn.setIconSize(QSize(18, 18))
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))
icon_name_zoom_reset = "zoom-original"
icon_path_zoom_reset = self.get_icon_path(icon_name_zoom_reset)
self.zoom_reset_btn = QPushButton()
self.zoom_reset_btn.setObjectName("zoomButton")
self.zoom_reset_btn.setToolTip("Reset Zoom (Ctrl+0)")
self.zoom_reset_btn.setFixedSize(32, 32)
if icon_path_zoom_reset:
self.zoom_reset_btn.setIcon(QIcon(str(icon_path_zoom_reset)))
self.zoom_reset_btn.setIconSize(QSize(18, 18))
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))
icon_name_zoom_in = "zoom-in"
icon_path_zoom_in = self.get_icon_path(icon_name_zoom_in)
self.zoom_in_btn = QPushButton()
self.zoom_in_btn.setObjectName("zoomButton")
self.zoom_in_btn.setToolTip("Zoom In (Ctrl++)")
self.zoom_in_btn.setFixedSize(32, 32)
if icon_path_zoom_in:
self.zoom_in_btn.setIcon(QIcon(str(icon_path_zoom_in)))
self.zoom_in_btn.setIconSize(QSize(18, 18))
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_toolbar)
self.log_text = ZoomableTextEdit(self)
self.log_text.setObjectName("logText")
self.log_text.setReadOnly(True)
min_font_size = max(9, self.log_font_size)
self.log_text.setFont(QFont("Consolas", min_font_size))
self.log_text.set_zoom_callbacks(self.zoom_in, self.zoom_out)
screen = self.screen().availableGeometry()
if screen.height() < 768:
self.log_text.setMinimumHeight(150)
else:
self.log_text.setMinimumHeight(200)
log_layout.addWidget(self.log_text)
card_layout.addWidget(log_section)
self.update_zoom_buttons()
return card
def create_button_sections(self):
"""Create modern organized button sections (responsive)"""
screen = self.screen().availableGeometry()
screen_width = screen.width()
if screen_width < 1024:
container_spacing = 12
elif screen_width < 1280:
container_spacing = 14
else:
container_spacing = 16
container = QWidget()
container_layout = QVBoxLayout(container)
container_layout.setSpacing(container_spacing)
container_layout.setContentsMargins(0, 0, 0, 0)
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)
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"),
("Enable OpenCL", self.enable_opencl_support, "Enable OpenCL support for hardware acceleration in Affinity applications", "lightning"),
]
)
container_layout.addWidget(sys_group)
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)
troubleshoot_group = self.create_button_group(
"Troubleshooting",
[
("Switch Wine Version", self.switch_wine_version, "Remove current Wine and install a different version (keeps your apps and settings)", "wine"),
("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"),
(self.get_switch_backend_button_text(), self.switch_graphics_backend, self.get_switch_backend_tooltip(), "lightning"),
("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)
patches_group = self.create_button_group(
"Patches",
[
("Return Colors (v3)", self.apply_return_colors, "Restore colored icons in Affinity v3 (replaces monochrome icons with v2 colored icons). Requires .NET SDK 10.0+", "wand"),
]
)
container_layout.addWidget(patches_group)
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_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 modern grouped button section (responsive)"""
screen = self.screen().availableGeometry()
screen_width = screen.width()
if screen_width < 1024:
card_spacing = 8
card_margin = 12
button_height = 40
icon_size = 18
elif screen_width < 1280:
card_spacing = 10
card_margin = 14
button_height = 42
icon_size = 20
else:
card_spacing = 12
card_margin = 16
button_height = 44
icon_size = 22
card = QFrame()
card.setObjectName("buttonCard")
card_layout = QVBoxLayout(card)
card_layout.setSpacing(card_spacing)
card_layout.setContentsMargins(card_margin, 16, card_margin, card_margin)
title_label = QLabel(title)
title_label.setObjectName("sectionTitle")
if screen_width < 1024:
title_label.setStyleSheet("font-size: 14px; font-weight: 600; background-color: transparent; border: none; padding: 0px;")
else:
title_label.setStyleSheet("background-color: transparent; border: none; padding: 0px;")
card_layout.addWidget(title_label)
buttons_layout = QVBoxLayout()
if screen_width < 1024:
buttons_layout.setSpacing(6)
else:
buttons_layout.setSpacing(8)
buttons_layout.setContentsMargins(0, 0, 0, 0)
for idx, button_data in enumerate(buttons):
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)
btn.setObjectName("actionButton")
if text == "One-Click Full Setup":
btn.setProperty("class", "primary")
btn.clicked.connect(lambda checked=False, b=btn, cmd=command: self._handle_button_click(b, cmd))
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(icon_size, icon_size))
self.icon_buttons.append((btn, icon_name))
if tooltip:
btn.setToolTip(tooltip)
btn.setMinimumHeight(button_height)
btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
if screen_width < 800:
btn.setMinimumWidth(220)
elif screen_width < 1024:
btn.setMinimumWidth(240)
else:
btn.setMinimumWidth(260)
buttons_layout.addWidget(btn)
if button_refs is not None and button_keys is not None and idx < len(button_keys):
button_refs[button_keys[idx]] = btn
if text.startswith("Switch to"):
self.switch_backend_button = btn
card_layout.addLayout(buttons_layout)
return card
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:
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 button in self._button_spinner_map:
return
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')
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
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()
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):
"""Load Affinity V3 icon (non-blocking - downloads in background if needed)"""
self.affinity_icon_path = None
def check_and_load_icon():
try:
icon_dir = Path.home() / ".local" / "share" / "icons"
icon_dir.mkdir(parents=True, exist_ok=True)
icon_path = icon_dir / "Affinity.svg"
if icon_path.exists():
try:
with open(icon_path, 'rb') as f:
first_bytes = f.read(100).decode('utf-8', errors='ignore')
if first_bytes.strip().startswith(' 0:
specs.append(f"CPU Cores: {cpu_count}")
except Exception:
pass
try:
mem_info = ""
if Path("/proc/meminfo").exists():
with open("/proc/meminfo", "r") as f:
mem_info = f.read()
if mem_info:
for line in mem_info.split("\n"):
if line.startswith("MemTotal:"):
mem_kb = int(line.split()[1])
mem_gb = mem_kb / (1024 * 1024)
specs.append(f"RAM: {mem_gb:.1f} GB")
break
except Exception:
pass
try:
gpu_info = []
if Path("/proc/driver/nvidia/version").exists():
try:
with open("/proc/driver/nvidia/version", "r") as f:
nvidia_version = f.read().strip()
gpu_info.append(f"NVIDIA Driver: {nvidia_version.split()[7] if len(nvidia_version.split()) > 7 else 'Detected'}")
except Exception:
pass
try:
result = subprocess.run(["lspci"], capture_output=True, text=True, timeout=5)
if result.returncode == 0 and result.stdout:
for line in result.stdout.split("\n"):
if "vga" in line.lower() or "3d" in line.lower() or "display" in line.lower():
gpu_line = line.split(":", 2)[-1].strip()
if gpu_line:
gpu_info.append(f"GPU: {gpu_line}")
break
except Exception:
pass
if gpu_info:
specs.extend(gpu_info)
except Exception:
pass
return specs
def _init_log_file(self):
"""Initialize log file"""
try:
self.log_file = open(self.log_file_path, 'a', encoding='utf-8')
log_header = f"\n{'='*80}\n"
log_header += f"Affinity Linux Installer - Session Started\n"
log_header += f"Timestamp: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
log_header += f"{'='*80}\n"
self.log_file.write(log_header)
self.log_file.flush()
except Exception as e:
self.log_file = None
def _log_safe(self, message, level="info"):
"""Thread-safe log handler (called from main thread)"""
timestamp = time.strftime("%H:%M:%S")
if level == "error":
icon = "❌"
color = "#ff7b72"
bg_color = "rgba(255, 123, 114, 0.1)"
icon_color = "#ff7b72"
elif level == "success":
icon = "✔"
color = "#6a9955"
bg_color = "rgba(106, 153, 85, 0.1)"
icon_color = "#6a9955"
elif level == "warning":
icon = "⚠️"
color = "#cd9731"
bg_color = "rgba(205, 151, 49, 0.1)"
icon_color = "#cd9731"
else:
icon = "•"
color = "#9cdcfe"
bg_color = "transparent"
icon_color = "#569cd6"
message = message.replace("<", "<").replace(">", ">")
timestamp_html = f'[{timestamp}]'
icon_html = f'{icon}'
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()
)
if self.log_file:
try:
plain_message = f"[{timestamp}] [{level.upper()}] {message}"
self.log_file.write(plain_message + "\n")
self.log_file.flush()
except Exception:
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"""
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
self.cancel_event.set()
self.update_progress_text("Cancelling...")
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)
self.cancel_btn.setVisible(False)
self.operation_in_progress = False
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
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
if hasattr(self, 'cancel_btn'):
self.cancel_btn.setVisible(False)
if self._operation_button is not None:
self.hide_spinner_signal.emit(self._operation_button)
self._operation_button = None
self._last_clicked_button = None
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)"""
msg_box = QMessageBox()
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStyleSheet(self.get_messagebox_stylesheet())
if msg_type == "error":
msg_box.setIcon(QMessageBox.Icon.Critical)
elif msg_type == "warning":
msg_box.setIcon(QMessageBox.Icon.Warning)
else:
msg_box.setIcon(QMessageBox.Icon.Information)
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok)
msg_box.exec()
def _request_sudo_password_safe(self):
"""Request sudo password from user (called from main thread)"""
dialog = QDialog()
dialog.setWindowTitle("Administrator Authentication Required")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing - improved for all screen sizes
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(350, int(screen_width * 0.9))
min_height = min(200, int(screen_height * 0.7))
default_width = min(450, int(screen_width * 0.85))
default_height = min(220, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 400
min_height = 200
default_width = 500
default_height = 240
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 400
min_height = 200
default_width = 500
default_height = 240
max_width = 700
max_height = 500
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
# Apply theme stylesheet
dialog.setStyleSheet(self.get_dialog_stylesheet())
# Main layout with responsive margins
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
title_label = QLabel("Administrator Authentication Required")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
desc_label = QLabel("This operation requires administrator privileges.\n\nPlease enter your password to continue:")
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
password_input = QLineEdit()
password_input.setEchoMode(QLineEdit.EchoMode.Password)
password_input.setPlaceholderText("Enter your password")
password_input.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(password_input)
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
password_input.returnPressed.connect(dialog.accept)
dialog.show()
dialog.raise_()
dialog.activateWindow()
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 self.sudo_password_validated and self.sudo_password:
return self.sudo_password
self.sudo_password = None
self.sudo_password_dialog_signal.emit()
max_wait = 300
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:
env = os.environ.copy()
env.pop('SUDO_ASKPASS', None)
process = subprocess.Popen(
["sudo", "-S", "true"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env=env,
preexec_fn=os.setsid
)
try:
stdout, stderr = process.communicate(input=f"{password}\n", timeout=15)
except subprocess.TimeoutExpired:
try:
if process.pid:
try:
pgid = os.getpgid(process.pid)
os.killpg(pgid, signal.SIGTERM)
time.sleep(0.5)
if process.poll() is None:
os.killpg(pgid, signal.SIGKILL)
except (ProcessLookupError, OSError, AttributeError):
process.kill()
except Exception:
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:
try:
if process.poll() is None:
process.wait(timeout=1)
except Exception:
pass
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_wine_version_dialog_safe(self):
"""Show professional Wine version selection dialog (called from main thread)"""
dialog = QDialog()
dialog.setWindowTitle("Choose Wine Version")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing - adapt to screen size and content
# Get screen size to adjust sizes
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
# Calculate optimal sizes based on screen size
# Account for 5 Wine version options now
if screen_width < 800 or screen_height < 600:
# Small screen - use smaller sizes
min_width = min(400, int(screen_width * 0.9))
min_height = min(350, int(screen_height * 0.7))
default_width = min(500, int(screen_width * 0.85))
default_height = min(450, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
# Medium screen
min_width = 500
min_height = 400
default_width = 650
default_height = 550
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
# Large screen
min_width = 550
min_height = 450
default_width = 700
default_height = 600
max_width = 900
max_height = 800
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
# Make dialog resizable
dialog.setSizeGripEnabled(True)
# Apply theme stylesheet
if self.dark_mode:
dialog_style = """
QDialog {
background-color: #252526;
color: #dcdcdc;
}
QLabel {
color: #dcdcdc;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4ec9b0;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #cccccc;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QLabel#optionDescription {
font-size: 13px;
color: #b0b0b0;
padding: 4px 0px 0px 0px;
line-height: 1.5;
}
QFrame#optionFrame {
background-color: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #4a4a4a;
background-color: #323232;
}
QRadioButton {
font-size: 16px;
color: #dcdcdc;
padding: 8px 0px;
spacing: 10px;
font-weight: 500;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #555555;
background-color: #3c3c3c;
}
QRadioButton::indicator:hover {
border-color: #6a6a6a;
}
QRadioButton::indicator:checked {
background-color: #4ec9b0;
border-color: #4ec9b0;
}
QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QPushButton:pressed {
background-color: #2d2d2d;
}
QPushButton#okButton, QPushButton#installButton {
background-color: #4ec9b0;
color: #1e1e1e;
border: 1px solid #4ec9b0;
font-weight: bold;
}
QPushButton#okButton:hover, QPushButton#installButton:hover {
background-color: #5dd9c0;
border-color: #5dd9c0;
}
QPushButton#okButton:pressed, QPushButton#installButton:pressed {
background-color: #3db9a0;
}
QScrollArea {
border: none;
background-color: transparent;
}
QScrollBar:vertical {
background-color: #2d2d2d;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555555;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #666666;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
"""
else:
dialog_style = """
QDialog {
background-color: #ffffff;
color: #2d2d2d;
}
QLabel {
color: #2d2d2d;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4caf50;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #555555;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QLabel#optionDescription {
font-size: 12px;
color: #666666;
padding: 4px 0px 0px 0px;
line-height: 1.4;
}
QFrame#optionFrame {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #c0c0c0;
background-color: #fafafa;
}
QRadioButton {
font-size: 16px;
color: #2d2d2d;
padding: 8px 0px;
spacing: 10px;
font-weight: 500;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #c0c0c0;
background-color: #ffffff;
}
QRadioButton::indicator:hover {
border-color: #a0a0a0;
}
QRadioButton::indicator:checked {
background-color: #4caf50;
border-color: #4caf50;
}
QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QPushButton:pressed {
background-color: #c0c0c0;
}
QPushButton#installButton {
background-color: #4caf50;
color: #ffffff;
border: 1px solid #4caf50;
font-weight: bold;
}
QPushButton#installButton:hover {
background-color: #45a049;
border-color: #45a049;
}
QPushButton#installButton:pressed {
background-color: #3d8b40;
}
QScrollArea {
border: none;
background-color: transparent;
}
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;
}
"""
dialog.setStyleSheet(dialog_style)
# Main layout with responsive margins
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
# Responsive margins - smaller on small screens
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("Choose Wine Version")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_label = QLabel(
"Select which Wine version you would like to install. "
"You can switch versions later by running 'Setup Wine Environment' again."
)
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
# Create button group to ensure only one radio button is selected at a time
button_group = QButtonGroup(dialog)
# Wine 11.0 option - clean frame with radio button and description (Recommended)
wine_110_frame = QFrame()
wine_110_frame.setObjectName("optionFrame")
wine_110_layout = QVBoxLayout(wine_110_frame)
wine_110_layout.setContentsMargins(12, 10, 12, 10)
wine_110_layout.setSpacing(6)
wine_110_radio = QRadioButton("Wine 11.0 (Recommended)")
wine_110_radio.setChecked(True)
wine_110_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
wine_110_layout.addWidget(wine_110_radio)
wine_110_desc = QLabel("ElementalWarrior Wine 11.0 with AMD GPU and OpenCL patches. Latest version with best compatibility and performance for most systems.")
wine_110_desc.setObjectName("optionDescription")
wine_110_desc.setWordWrap(True)
wine_110_desc.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
wine_110_layout.addWidget(wine_110_desc)
options_layout.addWidget(wine_110_frame)
button_group.addButton(wine_110_radio, 0)
# Wine 10.10 option - clean frame with radio button and description
wine_1010_frame = QFrame()
wine_1010_frame.setObjectName("optionFrame")
wine_1010_layout = QVBoxLayout(wine_1010_frame)
wine_1010_layout.setContentsMargins(12, 10, 12, 10)
wine_1010_layout.setSpacing(6)
wine_1010_radio = QRadioButton("Wine 10.10")
wine_1010_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
wine_1010_layout.addWidget(wine_1010_radio)
wine_1010_desc = QLabel("ElementalWarrior Wine 10.10 with AMD GPU and OpenCL patches. Previous stable version.")
wine_1010_desc.setObjectName("optionDescription")
wine_1010_desc.setWordWrap(True)
wine_1010_desc.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
wine_1010_layout.addWidget(wine_1010_desc)
options_layout.addWidget(wine_1010_frame)
button_group.addButton(wine_1010_radio, 1)
# Wine 9.14 option - clean frame with radio button and description
wine_914_frame = QFrame()
wine_914_frame.setObjectName("optionFrame")
wine_914_layout = QVBoxLayout(wine_914_frame)
wine_914_layout.setContentsMargins(12, 10, 12, 10)
wine_914_layout.setSpacing(6)
wine_914_radio = QRadioButton("Wine 9.14 (Legacy)")
wine_914_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
wine_914_layout.addWidget(wine_914_radio)
wine_914_desc = QLabel("Legacy version with AMD GPU and OpenCL patches. Fallback option if you encounter issues with newer versions.")
wine_914_desc.setObjectName("optionDescription")
wine_914_desc.setWordWrap(True)
wine_914_desc.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
wine_914_layout.addWidget(wine_914_desc)
options_layout.addWidget(wine_914_frame)
button_group.addButton(wine_914_radio, 2)
# Add scroll area to main layout with stretch factor
main_layout.addWidget(scroll_area, 1)
# Buttons - fixed at bottom, responsive sizing
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
# Get result
result = dialog.exec()
if result == QDialog.DialogCode.Accepted:
if wine_110_radio.isChecked():
self.question_dialog_response = "Wine 11.0 (Recommended)"
elif wine_1010_radio.isChecked():
self.question_dialog_response = "Wine 10.10"
elif wine_914_radio.isChecked():
self.question_dialog_response = "Wine 9.14 (Legacy)"
else:
# User cancelled - return "Cancel" to match expected format
self.question_dialog_response = "Cancel"
self.waiting_for_question_response = False
def _show_question_dialog_safe(self, title, message, buttons):
"""Show question dialog (called from main thread)"""
# Check if this is a Wine version selection dialog
is_wine_version_dialog = (
"Wine Version" in title or "Wine version" in title or
any("Wine 9.14" in btn or "Wine 10.10" in btn for btn in buttons)
)
if is_wine_version_dialog:
self._show_wine_version_dialog_safe()
return
# 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
# Create message box and apply theme
msg_box = QMessageBox()
msg_box.setWindowTitle(title)
msg_box.setText(message)
msg_box.setStandardButtons(qbuttons)
msg_box.setStyleSheet(self.get_messagebox_stylesheet())
reply = msg_box.exec()
# 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 indefinitely - let user take all the time they need
# Only exit if operation is actually cancelled by user (via cancel button)
while self.waiting_for_question_response:
# Check if operation was cancelled by user (not timeout)
if self.operation_cancelled:
self.waiting_for_question_response = False
return "Cancel"
time.sleep(0.1)
return self.question_dialog_response or "Cancel"
def detect_cpu_generation(self):
"""Detect CPU generation using the V1-V5 method based on CPU model name.
Returns:
str: CPU generation ("V1", "V2", "V3", "V4", "V5", or "Unknown")
bool: True if CPU is older (V1, V2, V3)
"""
try:
# Read CPU info from /proc/cpuinfo
cpu_info = ""
if Path("/proc/cpuinfo").exists():
with open("/proc/cpuinfo", "r") as f:
cpu_info = f.read()
# Try lscpu as fallback
if not cpu_info or "model name" not in cpu_info.lower():
try:
success, stdout, _ = self.run_command(["lscpu"], check=False, capture=True)
if success:
cpu_info = stdout
except Exception:
pass
if not cpu_info:
return "Unknown", False
cpu_info_lower = cpu_info.lower()
# AMD Detection (Zen architecture) - check in order from newest to oldest to avoid false matches
# V5: Zen 4 (2022-2023) - Ryzen 7000 (desktop), Ryzen 7040 (mobile)
if any(x in cpu_info_lower for x in ["ryzen 7", "ryzen 7000", "ryzen 7040"]):
return "V5", False
# V4: Zen 3 (2020-2021) and Zen 5 (2024-2025) - Ryzen 5000, Ryzen 9000, Ryzen AI 300
if any(x in cpu_info_lower for x in ["ryzen 5", "ryzen 5000", "ryzen 9", "ryzen 9000", "ryzen ai 300"]):
return "V4", False
# V3: Zen 2 (2019-2020) - Ryzen 3000 (desktop), Ryzen 4000U/H (mobile)
# Check for 4000 series first (mobile), then 3000 desktop (but not 3000U)
if any(x in cpu_info_lower for x in ["ryzen 4", "ryzen 4000"]):
return "V3", True
if "ryzen 3" in cpu_info_lower or "ryzen 3000" in cpu_info_lower:
# Check if it's not V2 (3000U is V2)
if "3000u" not in cpu_info_lower and "pro 3700u" not in cpu_info_lower:
return "V3", True
# V2: Zen+ (2018-2019) - Ryzen 2000 (desktop), Ryzen 3000U (mobile)
if any(x in cpu_info_lower for x in ["ryzen 3000u", "ryzen 7 pro 3700u"]):
return "V2", True
if "ryzen 2" in cpu_info_lower or "ryzen 2000" in cpu_info_lower:
# Check if it's not V1 (2000U is V1, 2000 desktop is V2)
if "2000u" not in cpu_info_lower:
return "V2", True
# V1: Zen (2017) - Ryzen 1000 (desktop), Ryzen 2000U (mobile)
if any(x in cpu_info_lower for x in ["ryzen 1", "ryzen 1000", "ryzen 2000u"]):
return "V1", True
# Intel Detection
# V1: Broadwell (5th Gen, 2014-2015) - i7-5600U, i5-5300U
if any(x in cpu_info_lower for x in ["i7-5600", "i5-5300", "broadwell"]):
return "V1", True
# V2: Skylake (6th Gen, 2015-2016) - i7-6600U, i5-6200U
if any(x in cpu_info_lower for x in ["i7-6600", "i5-6200", "skylake"]):
return "V2", True
# V3: Kaby Lake (7th Gen, 2016-2017), Coffee Lake (8th Gen, 2017-2018)
# i7-7600U, i7-8650U
if any(x in cpu_info_lower for x in ["i7-7600", "i7-8650", "kaby lake", "coffee lake"]):
return "V3", True
# V4: Ice Lake (10th Gen, 2019), Tiger Lake (11th Gen, 2020), Meteor Lake / Arrow Lake (14th Gen, 2024-2025)
# i7-1065G7, i7-1165G7, i7-14700K
if any(x in cpu_info_lower for x in ["i7-1065", "i7-1165", "i7-14700", "ice lake", "tiger lake", "meteor lake", "arrow lake"]):
return "V4", False
# V5: Alder Lake (12th Gen, 2021), Raptor Lake (13th Gen, 2022-2023)
# i7-12700K, i7-13700K
if any(x in cpu_info_lower for x in ["i7-12700", "i7-13700", "alder lake", "raptor lake"]):
return "V5", False
# Try to detect by generation number in model name
# Intel: Look for patterns like "Core i7-5xxx", "Core i5-6xxx", etc.
intel_gen_match = re.search(r'core\s+i[357]-([0-9])([0-9]{3})', cpu_info_lower)
if intel_gen_match:
gen_digit = int(intel_gen_match.group(1))
if gen_digit == 5:
return "V1", True
elif gen_digit == 6:
return "V2", True
elif gen_digit == 7:
return "V3", True
elif gen_digit in [10, 11, 14]:
return "V4", False
elif gen_digit in [12, 13]:
return "V5", False
# AMD: Look for Ryzen model numbers
amd_match = re.search(r'ryzen\s+([0-9])([0-9]{3})', cpu_info_lower)
if amd_match:
first_digit = int(amd_match.group(1))
if first_digit == 1:
return "V1", True
elif first_digit == 2:
# Could be V1 (2000U) or V2 (2000 desktop) - default to V2
return "V2", True
elif first_digit == 3:
# Could be V2 (3000U) or V3 (3000 desktop) - check for U suffix
if "u" in cpu_info_lower or "pro 3700u" in cpu_info_lower:
return "V2", True
return "V3", True
elif first_digit == 4:
return "V3", True
elif first_digit == 5:
return "V4", False
elif first_digit == 7:
return "V5", False
elif first_digit == 9:
return "V4", False
return "Unknown", False
except Exception as e:
self.log(f"Error detecting CPU generation: {e}", "warning")
return "Unknown", False
def get_wine_dir(self):
"""Get the Wine directory path"""
return Path(self.directory) / "ElementalWarriorWine"
def get_wine_path(self, binary="wine"):
"""Get the path to a Wine binary"""
return self.get_wine_dir() / "bin" / binary
def get_current_wine_version(self):
"""Get the current ElementalWarrior Wine version (9.14, 10.10, or 11.0)"""
# Try regular wine first
wine = self.get_wine_path("wine")
wine_staging = self.get_wine_path("wine-staging")
# Check both wine and wine-staging binaries
for wine_bin in [wine, wine_staging]:
if wine_bin.exists():
try:
success, stdout, _ = self.run_command([str(wine_bin), "--version"], check=False, capture=True)
if success and stdout:
version_match = re.search(r'wine-(\d+\.\d+)', stdout)
if version_match:
version = version_match.group(1)
# Map actual Wine version to ElementalWarrior version
if version.startswith("9."):
return "9.14"
elif version.startswith("10."):
return "10.10"
elif version.startswith("11."):
return "11.0"
except Exception:
continue
return None
def get_wine_tkg_for_installer(self, binary="wine"):
"""Get wine-tkg binary path for running installers, fallback to regular wine or wine-staging if not available"""
wine_tkg_bin = self.get_wine_tkg_path(binary)
if wine_tkg_bin and wine_tkg_bin.exists():
return str(wine_tkg_bin)
# Fallback to regular wine
wine_bin = self.get_wine_path(binary)
if wine_bin.exists():
return str(wine_bin)
# Final fallback to wine-staging
wine_staging_bin = self.get_wine_path(f"{binary}-staging")
if wine_staging_bin.exists():
return str(wine_staging_bin)
# Ultimate fallback
return str(wine_bin)
def get_wine_tkg_dir(self):
"""Get the wine-tkg directory path"""
return Path(self.directory) / "wine-tkg"
def get_wine_tkg_path(self, binary="wine"):
"""Get the path to a wine-tkg binary"""
self.log(f"DEBUG: get_wine_tkg_path() called for binary: {binary}", "info")
wine_tkg_dir = self.get_wine_tkg_dir()
self.log(f"DEBUG: wine-tkg directory: {wine_tkg_dir}", "info")
if not wine_tkg_dir.exists():
self.log(f"DEBUG: wine-tkg directory does not exist: {wine_tkg_dir}", "info")
return None
self.log(f"DEBUG: Checking for binary: {binary}", "info")
# Check if binary exists directly in wine_tkg_dir/bin/ (direct extraction)
direct_path = wine_tkg_dir / "bin" / binary
self.log(f"DEBUG: Checking direct path: {direct_path}", "info")
if direct_path.exists():
self.log(f"DEBUG: ✓ Found binary at direct path: {direct_path}", "info")
return direct_path
else:
self.log(f"DEBUG: ✗ Direct path does not exist", "info")
# Check if it's in a subdirectory (like wine-10.19-staging-amd64/bin/wine)
self.log(f"DEBUG: Checking subdirectories...", "info")
try:
subdirs = list(wine_tkg_dir.iterdir())
self.log(f"DEBUG: Found {len(subdirs)} items in wine-tkg directory", "info")
for subdir in subdirs:
if subdir.is_dir():
self.log(f"DEBUG: Checking subdirectory: {subdir.name}", "info")
# Check subdir/bin/binary
subdir_bin = subdir / "bin" / binary
self.log(f"DEBUG: Checking: {subdir_bin}", "info")
if subdir_bin.exists():
self.log(f"DEBUG: ✓ Found binary at: {subdir_bin}", "info")
return subdir_bin
# Also check if bin is directly in subdir (some archives might have different structure)
subdir_direct = subdir / binary
self.log(f"DEBUG: Checking direct: {subdir_direct}", "info")
if subdir_direct.exists():
self.log(f"DEBUG: ✓ Found binary at: {subdir_direct}", "info")
return subdir_direct
except Exception as e:
self.log(f"DEBUG: Error iterating subdirectories: {e}", "warning")
# Last resort: recursive search for the binary (but limit depth to avoid performance issues)
self.log(f"DEBUG: Performing recursive search for '{binary}'...", "info")
try:
found_paths = []
for path in wine_tkg_dir.rglob(binary):
if path.is_file() and path.name == binary:
found_paths.append(path)
# Make sure it's executable or at least looks like a binary
try:
is_executable = path.stat().st_mode & 0o111
has_no_suffix = path.suffix == ''
if is_executable or has_no_suffix:
self.log(f"DEBUG: ✓ Found binary via recursive search: {path}", "info")
return path
else:
self.log(f"DEBUG: Found '{binary}' but not executable: {path}", "info")
except Exception as e:
self.log(f"DEBUG: Error checking file {path}: {e}", "warning")
if found_paths:
self.log(f"DEBUG: Found {len(found_paths)} files named '{binary}' but none are valid binaries", "info")
for p in found_paths[:5]:
self.log(f"DEBUG: - {p}", "info")
except Exception as e:
self.log(f"DEBUG: Error during recursive search: {e}", "warning")
self.log(f"DEBUG: ✗ Binary '{binary}' not found in wine-tkg directory", "info")
return None
def _debug_log(self, message, level="info"):
"""Debug logging helper - prints to stderr (unbuffered) AND logs to UI/file AND debug file"""
# Use stderr which is unbuffered and more reliable for GUI apps
sys.stderr.write(f"[DEBUG] {message}\n")
sys.stderr.flush() # Force immediate output
# Also write to a debug log file
debug_log_path = Path.home() / "wine-tkg-debug.log"
try:
with open(debug_log_path, "a", encoding="utf-8") as f:
timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
f.write(f"[{timestamp}] {message}\n")
f.flush()
except Exception:
pass # Don't fail if we can't write to debug file
self.log(f"DEBUG: {message}", level)
def ensure_wine_tkg(self):
"""Download and extract wine-tkg if not already present"""
# Print to stderr as backup (visible in terminal, unbuffered)
sys.stderr.write("\n" + "="*80 + "\n")
sys.stderr.write("DEBUG: Starting wine-tkg setup process\n")
sys.stderr.write("="*80 + "\n")
sys.stderr.flush()
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
self.log("DEBUG: Starting wine-tkg setup process", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "info")
# Step 1: Get directory paths
self._debug_log("Step 1 - Getting directory paths")
wine_tkg_dir = self.get_wine_tkg_dir()
self._debug_log(f"wine-tkg directory: {wine_tkg_dir}")
self._debug_log(f"wine-tkg directory exists: {wine_tkg_dir.exists()}")
# Step 2: Check if already extracted
self._debug_log("Step 2 - Checking if wine-tkg is already available")
wine_tkg_bin = self.get_wine_tkg_path("wine")
self._debug_log(f"wine-tkg binary path: {wine_tkg_bin}")
if wine_tkg_bin:
exists = wine_tkg_bin.exists()
self._debug_log(f"wine-tkg binary exists check: {exists}")
if exists:
self._debug_log(f"✓ wine-tkg is already available at: {wine_tkg_bin}", "success")
return True
else:
self._debug_log(f"✗ wine-tkg path exists but file not found: {wine_tkg_bin}", "warning")
else:
self._debug_log("wine-tkg binary not found, will download and extract")
# Step 3: Setup download parameters
self.log("DEBUG: Step 3 - Setting up download parameters", "info")
wine_tkg_url = "https://github.com/Kron4ek/Wine-Builds/releases/download/11.0/wine-11.0-staging-tkg-amd64-wow64.tar.xz"
wine_tkg_file = wine_tkg_dir / "wine-11.0-staging-tkg-amd64-wow64.tar.xz"
self.log(f"DEBUG: Download URL: {wine_tkg_url}", "info")
self.log(f"DEBUG: Target file: {wine_tkg_file}", "info")
# Step 4: Create directory
self.log("DEBUG: Step 4 - Creating wine-tkg directory", "info")
try:
wine_tkg_dir.mkdir(parents=True, exist_ok=True)
self.log(f"DEBUG: ✓ Directory created/verified: {wine_tkg_dir}", "info")
self.log(f"DEBUG: Directory exists after creation: {wine_tkg_dir.exists()}", "info")
except Exception as e:
error_msg = f"Failed to create wine-tkg directory: {e}"
sys.stderr.write(f"ERROR: {error_msg}\n")
sys.stderr.write(f"Error type: {type(e).__name__}\n")
import traceback
sys.stderr.write(f"Traceback:\n{traceback.format_exc()}\n")
sys.stderr.flush()
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Error type: {type(e).__name__}", "error")
self.log(f"DEBUG: Traceback:\n{traceback.format_exc()}", "error")
return False
# Step 5: Check if file already exists
self.log("DEBUG: Step 5 - Checking if archive file already exists", "info")
if wine_tkg_file.exists():
file_size = wine_tkg_file.stat().st_size
self.log(f"DEBUG: Archive file already exists, size: {file_size} bytes", "info")
if file_size == 0:
self.log("DEBUG: Archive file is empty, will re-download", "warning")
try:
wine_tkg_file.unlink()
self.log("DEBUG: ✓ Empty file removed", "info")
except Exception as e:
self.log(f"DEBUG: ✗ Failed to remove empty file: {e}", "error")
else:
self.log("DEBUG: Archive file exists and has content, will use it", "info")
else:
self.log("DEBUG: Archive file does not exist, will download", "info")
# Step 6: Download wine-tkg
self.log("DEBUG: Step 6 - Downloading wine-tkg archive", "info")
self.log("Downloading wine-tkg...", "info")
sys.stderr.write(f"\n[WINE-TKG] Starting download from: {wine_tkg_url}\n")
sys.stderr.write(f"[WINE-TKG] Saving to: {wine_tkg_file}\n")
sys.stderr.flush()
try:
download_result = self.download_file(wine_tkg_url, str(wine_tkg_file), "wine-tkg")
sys.stderr.write(f"[WINE-TKG] Download result: {download_result}\n")
sys.stderr.flush()
self.log(f"DEBUG: Download result: {download_result}", "info")
except Exception as e:
error_msg = f"Exception during download: {e}"
sys.stderr.write(f"[WINE-TKG] ERROR: {error_msg}\n")
import traceback
sys.stderr.write(f"[WINE-TKG] Traceback:\n{traceback.format_exc()}\n")
sys.stderr.flush()
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
download_result = False
if not download_result:
error_msg = "Failed to download wine-tkg archive"
sys.stderr.write(f"\nERROR: {error_msg}\n")
sys.stderr.write("Possible causes:\n")
sys.stderr.write(f" - Network connectivity issues\n")
sys.stderr.write(f" - URL may be invalid or changed: {wine_tkg_url}\n")
sys.stderr.write(f" - Insufficient disk space\n")
sys.stderr.write(f" - Permission denied writing to: {wine_tkg_file.parent}\n")
if wine_tkg_file.exists():
file_size = wine_tkg_file.stat().st_size
sys.stderr.write(f" - Partial file exists with size: {file_size} bytes\n")
sys.stderr.flush()
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Possible causes:", "error")
self.log(f"DEBUG: - Network connectivity issues", "error")
self.log(f"DEBUG: - URL may be invalid or changed: {wine_tkg_url}", "error")
self.log(f"DEBUG: - Insufficient disk space", "error")
self.log(f"DEBUG: - Permission denied writing to: {wine_tkg_file.parent}", "error")
if wine_tkg_file.exists():
file_size = wine_tkg_file.stat().st_size
self.log(f"DEBUG: - Partial file exists with size: {file_size} bytes", "error")
return False
# Verify download
if wine_tkg_file.exists():
file_size = wine_tkg_file.stat().st_size
self.log(f"DEBUG: ✓ Download completed, file size: {file_size} bytes", "info")
if file_size == 0:
error_msg = "Downloaded file is empty"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: Download completed but file has 0 bytes", "error")
return False
else:
error_msg = "Download reported success but file not found"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: File should exist at: {wine_tkg_file}", "error")
return False
if self.check_cancelled():
self.log("DEBUG: Operation cancelled by user", "warning")
return False
# Step 7: Extract wine-tkg
self.log("DEBUG: Step 7 - Extracting wine-tkg archive", "info")
self.update_progress_text("Extracting wine-tkg...")
self.log("Extracting wine-tkg...", "info")
extraction_success = False
extraction_method = None
# Try Python lzma module first
self.log("DEBUG: Step 7a - Attempting extraction with Python lzma module", "info")
try:
import lzma
self.log("DEBUG: ✓ lzma module available", "info")
try:
self.log("DEBUG: Opening xz file with lzma...", "info")
with lzma.open(wine_tkg_file, 'rb') as xz_file:
self.log("DEBUG: ✓ xz file opened successfully", "info")
self.log("DEBUG: Opening tar archive...", "info")
with tarfile.open(fileobj=xz_file, mode='r') as tar:
self.log("DEBUG: ✓ tar archive opened successfully", "info")
# Check archive structure
self.log("DEBUG: Analyzing archive structure...", "info")
members = tar.getmembers()
self.log(f"DEBUG: Archive contains {len(members)} entries", "info")
if members:
first_member = members[0].name
self.log(f"DEBUG: First entry: '{first_member}'", "info")
# Show first few entries
for i, member in enumerate(members[:5]):
self.log(f"DEBUG: Entry {i+1}: {member.name} ({member.size} bytes)", "info")
# Try with filter='data' first (Python 3.12+)
self.log("DEBUG: Attempting extraction with filter='data' (Python 3.12+)...", "info")
try:
tar.extractall(wine_tkg_dir, filter='data')
extraction_success = True
extraction_method = "Python lzma with filter='data'"
self.log("DEBUG: ✓ Extraction successful with filter='data'", "info")
except TypeError as e:
self.log(f"DEBUG: filter='data' not supported: {e}", "info")
self.log("DEBUG: Attempting extraction without filter (older Python)...", "info")
tar.extractall(wine_tkg_dir)
extraction_success = True
extraction_method = "Python lzma without filter"
self.log("DEBUG: ✓ Extraction successful without filter", "info")
except Exception as e:
error_msg = f"Error during extraction with lzma: {e}"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Error type: {type(e).__name__}", "error")
import traceback
self.log(f"DEBUG: Traceback:\n{traceback.format_exc()}", "error")
except ImportError:
self.log("DEBUG: ✗ lzma module not available, will use xz command", "info")
extraction_method = None
# Fallback to xz command if lzma module not available or extraction failed
if not extraction_success:
self.log("DEBUG: Step 7b - Attempting extraction with xz command", "info")
# Check for xz command
xz_available = self.check_command("xz")
unxz_available = self.check_command("unxz")
self.log(f"DEBUG: xz command available: {xz_available}", "info")
self.log(f"DEBUG: unxz command available: {unxz_available}", "info")
if not xz_available and not unxz_available:
error_msg = "Neither xz nor unxz command is available"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: Required for extracting .tar.xz files when Python lzma module is unavailable", "error")
self.log(f"DEBUG: Solution: Install xz package (e.g., 'sudo pacman -S xz' or 'sudo apt install xz-utils')", "error")
return False
xz_cmd = "xz" if xz_available else "unxz"
self.log(f"DEBUG: Using command: {xz_cmd}", "info")
# Decompress with xz
tar_file = wine_tkg_file.with_suffix('.tar')
self.log(f"DEBUG: Decompressing to: {tar_file}", "info")
success, stdout, stderr = self.run_command([xz_cmd, "-d", "-k", str(wine_tkg_file)], check=True)
self.log(f"DEBUG: Decompression result: success={success}", "info")
if stdout:
self.log(f"DEBUG: Decompression stdout: {stdout[:200]}", "info")
if stderr:
self.log(f"DEBUG: Decompression stderr: {stderr[:200]}", "info")
if not success:
error_msg = "Failed to decompress wine-tkg archive with xz"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: xz command failed to decompress the archive", "error")
if stderr:
self.log(f"DEBUG: Error output: {stderr}", "error")
return False
if not tar_file.exists():
error_msg = "Decompression reported success but tar file not found"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: Expected tar file at: {tar_file}", "error")
return False
tar_size = tar_file.stat().st_size
self.log(f"DEBUG: ✓ Decompression successful, tar file size: {tar_size} bytes", "info")
# Extract tar file
self.log("DEBUG: Extracting tar archive...", "info")
try:
with tarfile.open(tar_file, "r") as tar:
self.log("DEBUG: ✓ tar file opened successfully", "info")
members = tar.getmembers()
self.log(f"DEBUG: Archive contains {len(members)} entries", "info")
try:
tar.extractall(wine_tkg_dir, filter='data')
extraction_success = True
extraction_method = "xz command + tar with filter='data'"
self.log("DEBUG: ✓ Extraction successful with filter='data'", "info")
except TypeError:
tar.extractall(wine_tkg_dir)
extraction_success = True
extraction_method = "xz command + tar without filter"
self.log("DEBUG: ✓ Extraction successful without filter", "info")
except Exception as e:
error_msg = f"Error extracting tar file: {e}"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Error type: {type(e).__name__}", "error")
import traceback
self.log(f"DEBUG: Traceback:\n{traceback.format_exc()}", "error")
return False
# Clean up intermediate tar file
if tar_file.exists():
try:
tar_file.unlink()
self.log("DEBUG: ✓ Intermediate tar file cleaned up", "info")
except Exception as e:
self.log(f"DEBUG: Warning: Failed to clean up tar file: {e}", "warning")
if not extraction_success:
error_msg = "Extraction did not complete successfully"
sys.stderr.write(f"\nERROR: {error_msg}\n")
sys.stderr.write("Cause: All extraction methods failed\n")
sys.stderr.flush()
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: All extraction methods failed", "error")
return False
self.log(f"DEBUG: ✓ Extraction completed using method: {extraction_method}", "info")
# Step 8: Clean up archive file
self.log("DEBUG: Step 8 - Cleaning up archive file", "info")
if wine_tkg_file.exists():
try:
wine_tkg_file.unlink()
self.log("DEBUG: ✓ Archive file cleaned up", "info")
except Exception as e:
self.log(f"DEBUG: Warning: Failed to clean up archive file: {e}", "warning")
# Step 9: Verify extraction
self.log("DEBUG: Step 9 - Verifying extraction", "info")
self.log(f"DEBUG: Checking extraction directory: {wine_tkg_dir}", "info")
if not wine_tkg_dir.exists():
error_msg = "Extraction directory does not exist after extraction"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: Directory was removed or never created: {wine_tkg_dir}", "error")
return False
# List extracted contents
try:
contents = list(wine_tkg_dir.iterdir())
self.log(f"DEBUG: Extracted directory contains {len(contents)} items", "info")
for i, item in enumerate(contents[:10]): # Show first 10 items
item_type = "directory" if item.is_dir() else "file"
size = f" ({item.stat().st_size} bytes)" if item.is_file() else ""
self.log(f"DEBUG: Item {i+1}: {item.name} ({item_type}){size}", "info")
if len(contents) > 10:
self.log(f"DEBUG: ... and {len(contents) - 10} more items", "info")
except Exception as e:
self.log(f"DEBUG: Warning: Failed to list directory contents: {e}", "warning")
# Step 10: Find wine binary
self.log("DEBUG: Step 10 - Searching for wine binary", "info")
wine_tkg_bin = self.get_wine_tkg_path("wine")
self.log(f"DEBUG: get_wine_tkg_path() returned: {wine_tkg_bin}", "info")
if wine_tkg_bin:
if wine_tkg_bin.exists():
self.log(f"DEBUG: ✓ wine binary found at: {wine_tkg_bin}", "success")
# Verify it's executable
try:
is_executable = wine_tkg_bin.stat().st_mode & 0o111
self.log(f"DEBUG: Binary is executable: {bool(is_executable)}", "info")
if not is_executable:
self.log("DEBUG: Warning: Binary is not executable, attempting to make it executable...", "warning")
try:
wine_tkg_bin.chmod(0o755)
self.log("DEBUG: ✓ Made binary executable", "info")
except Exception as e:
self.log(f"DEBUG: Warning: Failed to make executable: {e}", "warning")
except Exception as e:
self.log(f"DEBUG: Warning: Could not check executable bit: {e}", "warning")
self.log(f"wine-tkg extracted successfully at: {wine_tkg_bin}", "success")
return True
else:
error_msg = f"wine binary path exists but file not found: {wine_tkg_bin}"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: Path was returned but file does not exist", "error")
else:
error_msg = "wine binary not found after extraction"
sys.stderr.write(f"\nERROR: {error_msg}\n")
sys.stderr.write("Cause: get_wine_tkg_path() returned None\n")
sys.stderr.flush()
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Cause: get_wine_tkg_path() returned None", "error")
# Detailed search for debugging
sys.stderr.write("\nPerforming detailed search for wine binary...\n")
sys.stderr.write(f"Expected locations:\n")
sys.stderr.write(f" 1. {wine_tkg_dir / 'bin' / 'wine'}\n")
sys.stderr.write(f" 2. {wine_tkg_dir}/*/bin/wine (subdirectory)\n")
sys.stderr.flush()
self.log("DEBUG: Performing detailed search for wine binary...", "info")
self.log(f"DEBUG: Expected locations:", "info")
self.log(f"DEBUG: 1. {wine_tkg_dir / 'bin' / 'wine'}", "info")
self.log(f"DEBUG: 2. {wine_tkg_dir}/*/bin/wine (subdirectory)", "info")
# Search for any wine-related files
wine_files_found = []
try:
for item in wine_tkg_dir.rglob("*"):
if item.is_file() and "wine" in item.name.lower():
wine_files_found.append(item)
if len(wine_files_found) <= 10:
sys.stderr.write(f" Found wine-related file: {item.relative_to(wine_tkg_dir)}\n")
self.log(f"DEBUG: Found wine-related file: {item.relative_to(wine_tkg_dir)}", "info")
except Exception as e:
sys.stderr.write(f"Warning: Error during recursive search: {e}\n")
sys.stderr.flush()
self.log(f"DEBUG: Warning: Error during recursive search: {e}", "warning")
if wine_files_found:
sys.stderr.write(f"Found {len(wine_files_found)} wine-related files total\n")
sys.stderr.write("Most likely candidates:\n")
self.log(f"DEBUG: Found {len(wine_files_found)} wine-related files total", "info")
self.log(f"DEBUG: Most likely candidates:", "info")
for candidate in wine_files_found[:5]:
if candidate.name == "wine" or candidate.name.startswith("wine"):
sys.stderr.write(f" - {candidate}\n")
self.log(f"DEBUG: - {candidate}", "info")
else:
sys.stderr.write("No wine-related files found in extraction directory\n")
self.log("DEBUG: No wine-related files found in extraction directory", "error")
sys.stderr.write("\nERROR: wine-tkg extraction completed but binary not found\n")
sys.stderr.flush()
self.log("DEBUG: ✗ wine-tkg extraction completed but binary not found", "error")
return False
def get_winetricks_env_with_tkg(self, base_env=None):
"""Get environment for winetricks with wine-tkg in PATH"""
self.log("DEBUG: get_winetricks_env_with_tkg() called", "info")
if base_env is None:
env = os.environ.copy()
self.log("DEBUG: Created new environment from os.environ", "info")
else:
env = base_env.copy()
self.log("DEBUG: Created environment copy from base_env", "info")
self.log("DEBUG: Searching for wine-tkg binary...", "info")
wine_tkg_bin = self.get_wine_tkg_path("wine")
self.log(f"DEBUG: get_wine_tkg_path() returned: {wine_tkg_bin}", "info")
if wine_tkg_bin:
self.log(f"DEBUG: wine-tkg binary path: {wine_tkg_bin}", "info")
self.log(f"DEBUG: wine-tkg binary exists: {wine_tkg_bin.exists()}", "info")
if wine_tkg_bin.exists():
# Add wine-tkg bin directory to PATH so winetricks uses it
wine_tkg_bin_dir = wine_tkg_bin.parent
current_path = env.get("PATH", "")
env["PATH"] = f"{wine_tkg_bin_dir}:{current_path}"
self.log(f"DEBUG: ✓ Using wine-tkg from: {wine_tkg_bin_dir}", "info")
self.log(f"DEBUG: Updated PATH (first 200 chars): {env['PATH'][:200]}", "info")
self.log(f"Using wine-tkg from: {wine_tkg_bin_dir}", "info")
else:
error_msg = "wine-tkg binary path returned but file does not exist"
self.log(f"DEBUG: ✗ ERROR: {error_msg}", "error")
self.log(f"DEBUG: Path was: {wine_tkg_bin}", "error")
self.log("wine-tkg not found, using system wine", "warning")
else:
self.log("DEBUG: ✗ wine-tkg binary not found", "info")
self.log("wine-tkg not found, using system wine", "warning")
return env
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 = self.get_wine_path("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)
For Affinity v3, uses system wine instead of patched wine.
"""
# Check if this is Affinity v3 or WebView2 installer
installer_name = installer_file.name.lower()
is_affinity_v3 = "affinity" in installer_name and ("x64" in installer_name or "affinity-x64" in installer_name)
is_affinity_v2 = any(app in installer_name for app in ["photo", "designer", "publisher"]) and ".exe" in installer_name
is_webview2 = "webview" in installer_name or "edge" in installer_name
# Set Windows 11 before installing Affinity
if is_affinity_v3 or is_affinity_v2:
self.log("Setting Windows version to 11 before Affinity installation...", "info")
# Use system winecfg for Affinity installers (they use system wine)
self.run_command(["winecfg", "-v", "win11"], check=False, env=env)
self.log("✓ Windows version set to 11", "success")
# Use system Wine for Affinity installations (custom Wine doesn't work for installation)
if is_affinity_v3 or is_affinity_v2:
wine = "wine" # Use system Wine for installation
self.log("Using system Wine for Affinity installation", "info")
elif is_webview2:
# Use system wine for WebView2
wine = "wine"
self.log("Using system Wine for WebView2 installation", "info")
else:
# Use custom Wine for other installers
wine = str(self.get_wine_path("wine"))
# For Affinity installers, try direct execution first (more reliable)
if is_affinity_v3 or is_affinity_v2:
attempts = [
[wine, str(installer_file)],
[wine, "start", "/wait", "/unix", str(installer_file)],
]
else:
attempts = [
[wine, "start", "/wait", "/unix", str(installer_file)],
[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
# For Affinity installers, check if installer is actually running despite exceptions
if is_affinity_v3 or is_affinity_v2:
txt = (self._last_stream_output_text or "").lower()
# Check if we got a debugger exception but installer might still be running
if "unhandled exception" in txt or "winedbg" in txt:
self.log("Wine debugger exception detected, checking if installer is running...", "warning")
# Give it a moment to start
time.sleep(3)
if self._has_installer_activity(installer_file):
self.log("Installer is running despite exception message, continuing...", "info")
ok = True
# 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"]
# For Affinity installers, ignore debugger messages if installer is running
if is_affinity_v3 or is_affinity_v2:
# Double-check if installer is actually running
time.sleep(1)
if self._has_installer_activity(installer_file):
ok = True # Installer is running, ignore error markers
if any(m in txt for m in error_markers) and not (is_affinity_v3 or is_affinity_v2 and self._has_installer_activity(installer_file)):
ok = False
# For Affinity installers, even if ok is False, check if installer is actually running
if (is_affinity_v3 or is_affinity_v2) and not ok:
# Check one more time if installer is running
time.sleep(2)
if self._has_installer_activity(installer_file):
self.log("Installer is running despite error, continuing...", "info")
ok = True
if ok:
# For WebView2, use polling with timeout instead of indefinite wineserver wait
if is_webview2:
self.log("Waiting for WebView2 installer to complete (polling with timeout)...", "info")
max_wait_time = 600 # 10 minutes max
poll_interval = 2 # Check every 2 seconds
start_time = time.time()
while time.time() - start_time < max_wait_time:
if self.check_cancelled():
return False
# Check if installer process/window is still active
if not self._has_installer_activity(installer_file):
# No installer activity - wait a bit more to ensure it's really done
time.sleep(3)
# Double-check it's still inactive
if not self._has_installer_activity(installer_file):
# Also verify WebView2 was actually installed
webview2_paths = [
Path(self.directory) / "drive_c" / "Program Files (x86)" / "Microsoft" / "EdgeWebView" / "Application",
Path(self.directory) / "drive_c" / "Program Files" / "Microsoft" / "EdgeWebView" / "Application",
]
installed = any(
(path / "msedgewebview2.exe").exists()
for path in webview2_paths
)
if installed:
self.log("WebView2 installer completed and files detected", "success")
else:
self.log("WebView2 installer appears to have completed (files not yet detected)", "info")
break
time.sleep(poll_interval)
else:
self.log("WebView2 installer timeout reached - proceeding anyway", "warning")
# Final wineserver wait with short timeout
env_wait = env.copy() if env else os.environ.copy()
env_wait["WINEPREFIX"] = self.directory
try:
# Use timeout for wineserver wait (30 seconds max)
process = subprocess.Popen(
["wineserver", "-w"],
env=env_wait,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
process.wait(timeout=30)
except subprocess.TimeoutExpired:
self.log("Wineserver wait timed out - installer may have finished", "warning")
process.kill()
except Exception as e:
self.log(f"Wineserver wait error (non-critical): {e}", "warning")
else:
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
# Use system wineserver (always use system wineserver, not patched one)
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 (optimized)"""
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)
# Check if local icons directory exists and has icons (fast path)
local_icons_dir = Path(__file__).parent / "icons"
if local_icons_dir.exists():
# If local icons exist, copy them quickly instead of downloading
try:
local_icons = list(local_icons_dir.glob("*.svg"))
if local_icons:
# Copy missing icons from local directory
for local_icon in local_icons:
dest_icon = icons_dir / local_icon.name
if not dest_icon.exists():
shutil.copy2(local_icon, dest_icon)
return # Fast path - use local icons
except Exception:
pass # Fall through to download if copy fails
# List of UI theme icons to download from GitHub (only if local icons don't exist)
# Note: Application icons (Affinity.png, etc.) are downloaded elsewhere
# These are just the UI button icons needed for the installer interface
icon_files = [
# Zoom icons
("zoom-in-dark.svg", "AffinityScripts/icons/zoom-in-dark.svg"),
("zoom-in-light.svg", "AffinityScripts/icons/zoom-in-light.svg"),
("zoom-out-dark.svg", "AffinityScripts/icons/zoom-out-dark.svg"),
("zoom-out-light.svg", "AffinityScripts/icons/zoom-out-light.svg"),
("zoom-original-dark.svg", "AffinityScripts/icons/zoom-original-dark.svg"),
("zoom-original-light.svg", "AffinityScripts/icons/zoom-original-light.svg"),
# Action icons
("rocket-dark.svg", "AffinityScripts/icons/rocket-dark.svg"),
("rocket-light.svg", "AffinityScripts/icons/rocket-light.svg"),
("wine-dark.svg", "AffinityScripts/icons/wine-dark.svg"),
("wine-light.svg", "AffinityScripts/icons/wine-light.svg"),
("dependencies-dark.svg", "AffinityScripts/icons/dependencies-dark.svg"),
("dependencies-light.svg", "AffinityScripts/icons/dependencies-light.svg"),
("wand-dark.svg", "AffinityScripts/icons/wand-dark.svg"),
("wand-light.svg", "AffinityScripts/icons/wand-light.svg"),
("download-dark.svg", "AffinityScripts/icons/download-dark.svg"),
("download-light.svg", "AffinityScripts/icons/download-light.svg"),
("folderopen-dark.svg", "AffinityScripts/icons/folderopen-dark.svg"),
("folderopen-light.svg", "AffinityScripts/icons/folderopen-light.svg"),
("camera-dark.svg", "AffinityScripts/icons/camera-dark.svg"),
("camera-light.svg", "AffinityScripts/icons/camera-light.svg"),
("pen-dark.svg", "AffinityScripts/icons/pen-dark.svg"),
("pen-light.svg", "AffinityScripts/icons/pen-light.svg"),
("book-dark.svg", "AffinityScripts/icons/book-dark.svg"),
("book-light.svg", "AffinityScripts/icons/book-light.svg"),
("windows-dark.svg", "AffinityScripts/icons/windows-dark.svg"),
("windows-light.svg", "AffinityScripts/icons/windows-light.svg"),
("display-dark.svg", "AffinityScripts/icons/display-dark.svg"),
("display-light.svg", "AffinityScripts/icons/display-light.svg"),
("lightning-dark.svg", "AffinityScripts/icons/lightning-dark.svg"),
("lightning-light.svg", "AffinityScripts/icons/lightning-light.svg"),
("loop-dark.svg", "AffinityScripts/icons/loop-dark.svg"),
("loop-light.svg", "AffinityScripts/icons/loop-light.svg"),
("chrome-dark.svg", "AffinityScripts/icons/chrome-dark.svg"),
("chrome-light.svg", "AffinityScripts/icons/chrome-light.svg"),
("cog-dark.svg", "AffinityScripts/icons/cog-dark.svg"),
("cog-light.svg", "AffinityScripts/icons/cog-light.svg"),
("scale-dark.svg", "AffinityScripts/icons/scale-dark.svg"),
("scale-light.svg", "AffinityScripts/icons/scale-light.svg"),
("trash-dark.svg", "AffinityScripts/icons/trash-dark.svg"),
("trash-light.svg", "AffinityScripts/icons/trash-light.svg"),
("play-dark.svg", "AffinityScripts/icons/play-dark.svg"),
("play-light.svg", "AffinityScripts/icons/play-light.svg"),
("exit-dark.svg", "AffinityScripts/icons/exit-dark.svg"),
("exit-light.svg", "AffinityScripts/icons/exit-light.svg"),
("affinity-unified-dark.svg", "AffinityScripts/icons/affinity-unified-dark.svg"),
("affinity-unified-light.svg", "AffinityScripts/icons/affinity-unified-light.svg"),
]
# 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 in parallel for speed (no log messages)
base_url = "https://raw.githubusercontent.com/seapear/AffinityOnLinux/main/"
def download_icon(local_name, github_path):
"""Download a single icon"""
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 timeout
urllib.request.urlretrieve(icon_url, str(icon_path))
except Exception:
# Silently fail - icons are not critical for functionality
pass
# Download icons in parallel (limit to 5 concurrent downloads to avoid overwhelming)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=5) as executor:
executor.map(lambda args: download_icon(*args), missing_icons)
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
# GREP defined more explicitly, avoids wrong lines
if any(keyword in line_lower for keyword in ['vga', '3d controller', '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 has_amd_gpu(self):
"""Check if system has an AMD GPU"""
gpus = self.detect_gpus()
return any(gpu["type"] == "amd" for gpu in gpus)
def has_nvidia_gpu(self):
"""Check if system has an NVIDIA GPU"""
gpus = self.detect_gpus()
return any(gpu["type"] == "nvidia" for gpu in gpus)
def get_dxvk_vkd3d_preference(self):
"""Get DXVK/vkd3d preference for NVIDIA users"""
pref_file = Path(self.directory) / ".dxvk_vkd3d_preference"
if pref_file.exists():
try:
with open(pref_file, 'r') as f:
return f.read().strip()
except Exception:
return None
return None
def set_dxvk_vkd3d_preference(self, preference):
"""Set DXVK/vkd3d preference for NVIDIA users (either 'dxvk' or 'vkd3d')"""
pref_file = Path(self.directory) / ".dxvk_vkd3d_preference"
try:
with open(pref_file, 'w') as f:
f.write(preference)
return True
except Exception as e:
self.log(f"Failed to save DXVK/vkd3d preference: {e}", "error")
return False
def _show_nvidia_dxvk_vkd3d_choice_safe(self):
"""Show NVIDIA DXVK/vkd3d choice dialog (called from main thread)"""
# Check if preference already exists
existing_pref = self.get_dxvk_vkd3d_preference()
if existing_pref in ["dxvk", "vkd3d"]:
self.nvidia_dxvk_vkd3d_choice_response = existing_pref
self.waiting_for_nvidia_choice = False
return
# Create a custom dialog
dialog = QDialog()
dialog.setWindowTitle("Choose Graphics Backend for NVIDIA GPU")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing - improved for all screen sizes
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(400, int(screen_width * 0.9))
min_height = min(300, int(screen_height * 0.7))
default_width = min(500, int(screen_width * 0.85))
default_height = min(350, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 450
min_height = 320
default_width = 550
default_height = 380
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 450
min_height = 320
default_width = 550
default_height = 380
max_width = 800
max_height = 700
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
# Apply theme stylesheet matching main UI
if self.dark_mode:
dialog_style = """
QDialog {
background-color: #252526;
color: #dcdcdc;
}
QLabel {
color: #dcdcdc;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4ec9b0;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #cccccc;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QFrame#optionFrame {
background-color: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #4a4a4a;
background-color: #323232;
}
QRadioButton {
font-size: 16px;
color: #dcdcdc;
padding: 8px 0px;
spacing: 10px;
font-weight: 500;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #555555;
background-color: #3c3c3c;
}
QRadioButton::indicator:hover {
border-color: #6a6a6a;
}
QRadioButton::indicator:checked {
background-color: #4ec9b0;
border-color: #4ec9b0;
}
QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QPushButton:pressed {
background-color: #2d2d2d;
}
QPushButton#okButton {
background-color: #4ec9b0;
color: #1e1e1e;
border: 1px solid #4ec9b0;
font-weight: bold;
}
QPushButton#okButton:hover {
background-color: #5dd9c0;
border-color: #5dd9c0;
}
QPushButton#okButton:pressed {
background-color: #3db9a0;
}
"""
else:
dialog_style = """
QDialog {
background-color: #ffffff;
color: #2d2d2d;
}
QLabel {
color: #2d2d2d;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4caf50;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #555555;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QLabel#optionDescription {
font-size: 12px;
color: #666666;
padding: 4px 0px 0px 0px;
line-height: 1.4;
}
QFrame#optionFrame {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #c0c0c0;
background-color: #fafafa;
}
QRadioButton {
font-size: 14px;
color: #2d2d2d;
padding: 8px 0px;
spacing: 10px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #c0c0c0;
background-color: #ffffff;
}
QRadioButton::indicator:hover {
border-color: #a0a0a0;
}
QRadioButton::indicator:checked {
background-color: #4caf50;
border-color: #4caf50;
}
QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QPushButton:pressed {
background-color: #c0c0c0;
}
QPushButton#okButton {
background-color: #4caf50;
color: #ffffff;
border: 1px solid #4caf50;
font-weight: bold;
}
QPushButton#okButton:hover {
background-color: #45a049;
border-color: #45a049;
}
QPushButton#okButton:pressed {
background-color: #3d8b40;
}
QScrollArea {
border: none;
background-color: transparent;
}
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;
}
"""
dialog.setStyleSheet(dialog_style)
# Main layout with responsive margins
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("NVIDIA GPU Detected")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_label = QLabel(
"Please choose your preferred graphics backend:\n\n"
"• vkd3d - Includes OpenCL support for hardware acceleration\n"
"• DXVK - Hardware accelerated, uses the GPU (no OpenCL)\n\n"
"Note: You can change this later if needed."
)
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
# Radio buttons in styled frames
button_group = QButtonGroup()
# vkd3d option
vkd3d_frame = QFrame()
vkd3d_frame.setObjectName("optionFrame")
vkd3d_layout = QVBoxLayout(vkd3d_frame)
vkd3d_layout.setContentsMargins(12, 10, 12, 10)
vkd3d_radio = QRadioButton("vkd3d (with OpenCL support)")
vkd3d_radio.setChecked(True)
vkd3d_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
vkd3d_layout.addWidget(vkd3d_radio)
options_layout.addWidget(vkd3d_frame)
# DXVK option
dxvk_frame = QFrame()
dxvk_frame.setObjectName("optionFrame")
dxvk_layout = QVBoxLayout(dxvk_frame)
dxvk_layout.setContentsMargins(12, 10, 12, 10)
dxvk_radio = QRadioButton("DXVK (hardware accelerated, no OpenCL)")
dxvk_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
dxvk_layout.addWidget(dxvk_radio)
options_layout.addWidget(dxvk_frame)
button_group.addButton(vkd3d_radio, 0)
button_group.addButton(dxvk_radio, 1)
main_layout.addWidget(scroll_area, 1)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
# Get result
if dialog.exec() == QDialog.DialogCode.Accepted:
if vkd3d_radio.isChecked():
preference = "vkd3d"
else:
preference = "dxvk"
self.set_dxvk_vkd3d_preference(preference)
self.nvidia_dxvk_vkd3d_choice_response = preference
else:
# User cancelled - default to vkd3d
self.set_dxvk_vkd3d_preference("vkd3d")
self.nvidia_dxvk_vkd3d_choice_response = "vkd3d"
self.waiting_for_nvidia_choice = False
def ask_nvidia_dxvk_vkd3d_choice(self):
"""Ask NVIDIA users to choose between DXVK and vkd3d (thread-safe)"""
# Check if preference already exists
existing_pref = self.get_dxvk_vkd3d_preference()
if existing_pref in ["dxvk", "vkd3d"]:
return existing_pref
# Show dialog using signal (thread-safe)
self.nvidia_dxvk_vkd3d_choice_response = None
self.waiting_for_nvidia_choice = True
self.nvidia_dxvk_vkd3d_choice_signal.emit()
# Wait for response with timeout
max_wait = 300 # 30 seconds
waited = 0
while self.waiting_for_nvidia_choice and waited < max_wait:
time.sleep(0.1)
waited += 1
return self.nvidia_dxvk_vkd3d_choice_response or "vkd3d"
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 get_current_backend(self):
"""Detect which graphics backend is currently being used (dxvk or vkd3d)"""
# Check preference first (applies to all GPU types)
preference = self.get_dxvk_vkd3d_preference()
if preference == "dxvk":
return "dxvk"
elif preference == "vkd3d":
return "vkd3d"
# If no preference set, check if vkd3d DLLs exist
wine_lib_dir = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
if wine_lib_dir.exists() and (wine_lib_dir / "d3d12.dll").exists():
return "vkd3d"
# Default to DXVK (for AMD, NVIDIA, and other GPUs)
return "dxvk"
def get_dxvk_env_vars(self):
"""Get DXVK environment variables for AMD GPU or NVIDIA GPU with DXVK preference"""
if self.has_amd_gpu():
return "DXVK_ASYNC=0 DXVK_CONFIG=\"d3d9.deferSurfaceCreation = True; d3d9.shaderModel = 1\" "
elif self.has_nvidia_gpu():
preference = self.get_dxvk_vkd3d_preference()
if preference == "dxvk":
return "DXVK_ASYNC=0 DXVK_CONFIG=\"d3d9.deferSurfaceCreation = True; d3d9.shaderModel = 1\" "
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 (without parent to avoid threading issues)
dialog = QDialog()
dialog.setWindowTitle("GPU Selection for Affinity Applications")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(400, int(screen_width * 0.9))
min_height = min(300, int(screen_height * 0.8))
default_width = min(500, int(screen_width * 0.85))
default_height = min(350, int(screen_height * 0.7))
else:
min_width = 450
min_height = 320
default_width = 550
default_height = 380
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
# Apply theme stylesheet
dialog.setStyleSheet(self.get_dialog_stylesheet())
# Main layout with responsive margins
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("GPU Selection for Affinity Applications")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_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)."
)
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
# Create radio buttons for each GPU
button_group = QButtonGroup(dialog)
radio_buttons = []
# Add "Auto" option first
auto_frame = QFrame()
auto_frame.setObjectName("optionFrame")
auto_layout = QVBoxLayout(auto_frame)
auto_layout.setContentsMargins(12, 10, 12, 10)
auto_radio = QRadioButton("Auto (System Default)")
auto_radio.setChecked(current_gpu == "auto")
auto_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
auto_layout.addWidget(auto_radio)
options_layout.addWidget(auto_frame)
button_group.addButton(auto_radio, -1)
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_frame = QFrame()
gpu_frame.setObjectName("optionFrame")
gpu_layout = QVBoxLayout(gpu_frame)
gpu_layout.setContentsMargins(12, 10, 12, 10)
gpu_label = f"{gpu['name']} ({gpu['type'].upper()})"
radio = QRadioButton(gpu_label)
radio.setChecked(current_gpu == gpu["id"])
radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
gpu_layout.addWidget(radio)
options_layout.addWidget(gpu_frame)
button_group.addButton(radio, gpus.index(gpu))
radio_buttons.append((gpu["id"], radio))
main_layout.addWidget(scroll_area, 1)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
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 get_switch_backend_button_text(self):
"""Get the text for the switch backend button based on current backend"""
current = self.get_current_backend()
if current == "dxvk":
return "Switch to VKD3D"
else:
return "Switch to DXVK"
def get_switch_backend_tooltip(self):
"""Get the tooltip for the switch backend button based on current backend"""
current = self.get_current_backend()
if current == "dxvk":
return "Switch from DXVK to VKD3D (includes OpenCL support)"
else:
return "Switch from VKD3D to DXVK for graphics acceleration"
def update_switch_backend_button(self):
"""Update the switch backend button text and tooltip"""
if self.switch_backend_button:
self.switch_backend_button.setText(self.get_switch_backend_button_text())
self.switch_backend_button.setToolTip(self.get_switch_backend_tooltip())
def switch_graphics_backend(self):
"""Switch between DXVK and VKD3D based on current backend"""
current = self.get_current_backend()
if current == "dxvk":
self.switch_to_vkd3d()
else:
self.switch_to_dxvk()
def switch_to_vkd3d(self):
"""Switch from DXVK to VKD3D, installing vkd3d-proton"""
# Confirm with user
reply = QMessageBox.question(
self,
"Switch to VKD3D",
"This will:\n\n"
"• Install vkd3d-proton for OpenCL support\n"
"• Install d3d12.dll and d3d12core.dll\n"
"• Set preference to use VKD3D\n"
"• Update all desktop entries to remove DXVK environment variables\n\n"
"Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Switching to VKD3D", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
try:
# 1. Install vkd3d-proton (full setup)
self.log("Installing vkd3d-proton...", "info")
self.setup_vkd3d()
# 2. Set preference to VKD3D
self.set_dxvk_vkd3d_preference("vkd3d")
self.log("Set preference to VKD3D", "success")
# 3. Remove DXVK DLL overrides and DLLs from system32 (if any)
self.remove_dxvk_overrides()
# 4. Set up DLL overrides for vkd3d
self.log("Setting up DLL overrides for vkd3d...", "info")
self.setup_d3d12_overrides()
# 5. Copy DLLs to application directories
self.log("Copying d3d12 DLLs to application directories...", "info")
wine_lib_dir = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
app_dirs = {
"Photo": "Photo 2",
"Designer": "Designer 2",
"Publisher": "Publisher 2",
"Add": "Affinity"
}
for app_name, app_dir_name in app_dirs.items():
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / app_dir_name
if app_dir.exists():
for dll in ["d3d12.dll", "d3d12core.dll"]:
for source in [wine_lib_dir / dll, vkd3d_temp / dll]:
if source.exists():
shutil.copy2(source, app_dir / dll)
self.log(f"Copied {dll} to {app_dir_name}", "success")
break
# 6. Update all desktop entries (remove DXVK env vars)
self.log("Updating desktop entries (removing DXVK environment variables)...", "info")
desktop_dir = Path.home() / ".local" / "share" / "applications"
if not desktop_dir.exists():
self.log("Desktop directory not found", "warning")
else:
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()
# Extract app path
quoted_path_match = re.search(r'wine\s+"([^"]+)"', exec_content)
if quoted_path_match:
app_path = quoted_path_match.group(1)
else:
exe_match = re.search(r'wine\s+([^\s]+\.exe[^\s]*)', exec_content)
if exe_match:
app_path = exe_match.group(1)
else:
exe_match = re.search(r'([^\s]+\.exe[^\s]*)', exec_content)
if exe_match:
app_path = exe_match.group(1).strip('"')
else:
parts = exec_content.split()
for part in reversed(parts):
if ".exe" in part or "drive_c" in part:
app_path = part.strip('"')
break
else:
app_path = None
# Get wine path
wine = self.get_wine_path("wine")
wine_path = str(wine)
# Get GPU environment variables (but NOT DXVK)
gpu_env = self.get_gpu_env_vars()
directory_str = str(self.directory).rstrip("/")
# Rebuild Exec line WITHOUT DXVK 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:
if ' ' in app_path or not app_path.startswith('/'):
exec_line += f' "{app_path}"'
else:
exec_line += f' {app_path}'
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}", "success")
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", "success")
# Update button text
self.update_switch_backend_button()
self.show_message(
"Switch to VKD3D Complete",
f"Successfully switched to VKD3D!\n\n"
f"• Installed vkd3d-proton with OpenCL support\n"
f"• Installed d3d12.dll and d3d12core.dll\n"
f"• Removed DXVK DLL overrides\n"
f"• Set up DLL overrides for d3d12 and d3d12core in Wine registry\n"
f"• Updated {updated_count} desktop entry/entries\n"
f"• All Affinity applications will now use VKD3D with OpenCL support",
"info"
)
except Exception as e:
self.log(f"Error switching to VKD3D: {e}", "error")
self.show_message(
"Error",
f"An error occurred while switching to VKD3D:\n\n{e}",
"error"
)
def switch_to_dxvk(self):
"""Remove vkd3d and switch to DXVK using winetricks, updating all desktop entries"""
# Confirm with user
reply = QMessageBox.question(
self,
"Switch to DXVK",
"This will:\n\n"
"• Remove vkd3d-proton DLLs from Wine and application directories\n"
"• Install DXVK via winetricks\n"
"• Reinstall d3d12.dll and d3d12core.dll (required for compatibility)\n"
"• Set preference to use DXVK instead\n"
"• Update all desktop entries to use DXVK environment variables\n\n"
"Continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Switching to DXVK", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
try:
# 0. Kill wineserver to avoid version mismatch issues
self.log("Stopping wineserver to avoid version conflicts...", "info")
wineserver = self.get_wine_path("wineserver")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
self.run_command([str(wineserver), "-k"], check=False, env=env, capture=True)
import time
time.sleep(1) # Brief pause to ensure wineserver has stopped
self.log("Wineserver stopped", "success")
# 1. Remove vkd3d DLLs from Wine library directory
wine_lib_dir = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
if wine_lib_dir.exists():
self.log("Removing vkd3d DLLs from Wine library directory...", "info")
for dll in ["d3d12.dll", "d3d12core.dll", "dxgi.dll"]:
dll_path = wine_lib_dir / dll
if dll_path.exists():
dll_path.unlink()
self.log(f"Removed {dll} from Wine library", "success")
# Try to remove parent directories if empty
try:
if wine_lib_dir.exists() and not any(wine_lib_dir.iterdir()):
wine_lib_dir.rmdir()
parent = wine_lib_dir.parent
if parent.exists() and not any(parent.iterdir()):
parent.rmdir()
except Exception:
pass # Ignore errors removing directories
# 2. Remove vkd3d_dlls directory
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
if vkd3d_temp.exists():
self.log("Removing vkd3d_dlls directory...", "info")
try:
shutil.rmtree(vkd3d_temp)
self.log("Removed vkd3d_dlls directory", "success")
except Exception as e:
self.log(f"Warning: Could not remove vkd3d_dlls directory: {e}", "warning")
# 3. Remove vkd3d DLLs from application directories
app_dirs = {
"Photo": "Photo 2",
"Designer": "Designer 2",
"Publisher": "Publisher 2",
"Add": "Affinity"
}
for app_name, app_dir_name in app_dirs.items():
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / app_dir_name
if app_dir.exists():
for dll in ["d3d12.dll", "d3d12core.dll"]:
dll_path = app_dir / dll
if dll_path.exists():
dll_path.unlink()
self.log(f"Removed {dll} from {app_dir_name}", "success")
# 4. Set preference to DXVK
self.set_dxvk_vkd3d_preference("dxvk")
self.log("Set preference to DXVK", "success")
# 5. Remove vkd3d DLL overrides (if any)
self.remove_d3d12_overrides()
# 6. Install DXVK via winetricks
self.log("Installing DXVK via winetricks...", "info")
self.install_dxvk_dlls()
# 7. Reinstall d3d12 DLLs and overrides (needed even with DXVK)
self.log("Reinstalling d3d12 DLLs and setting up DLL overrides...", "info")
self.install_d3d12_dlls()
# 8. Copy DLLs back to application directories
self.log("Copying d3d12 DLLs to application directories...", "info")
wine_lib_dir = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
for app_name, app_dir_name in app_dirs.items():
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / app_dir_name
if app_dir.exists():
for dll in ["d3d12.dll", "d3d12core.dll"]:
# Try wine library first, then temp directory
for source in [wine_lib_dir / dll, vkd3d_temp / dll]:
if source.exists():
shutil.copy2(source, app_dir / dll)
self.log(f"Copied {dll} to {app_dir_name}", "success")
break
# 9. Update all desktop entries
self.log("Updating desktop entries with DXVK environment variables...", "info")
desktop_dir = Path.home() / ".local" / "share" / "applications"
if not desktop_dir.exists():
self.log("Desktop directory not found", "warning")
else:
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
# Extract app path
quoted_path_match = re.search(r'wine\s+"([^"]+)"', exec_content)
if quoted_path_match:
app_path = quoted_path_match.group(1)
else:
exe_match = re.search(r'wine\s+([^\s]+\.exe[^\s]*)', exec_content)
if exe_match:
app_path = exe_match.group(1)
else:
exe_match = re.search(r'([^\s]+\.exe[^\s]*)', exec_content)
if exe_match:
app_path = exe_match.group(1).strip('"')
else:
parts = exec_content.split()
for part in reversed(parts):
if ".exe" in part or "drive_c" in part:
app_path = part.strip('"')
break
else:
app_path = None
# Get wine path
wine = self.get_wine_path("wine")
wine_path = str(wine)
# Get GPU and DXVK environment variables
gpu_env = self.get_gpu_env_vars()
dxvk_env = self.get_dxvk_env_vars()
directory_str = str(self.directory).rstrip("/")
# Rebuild Exec line with DXVK env vars
exec_line = f'Exec=env WINEPREFIX={directory_str}'
if gpu_env:
exec_line += f' {gpu_env}'
if dxvk_env:
exec_line += f' {dxvk_env}'
exec_line += f' {wine_path}'
if app_path:
if ' ' in app_path or not app_path.startswith('/'):
exec_line += f' "{app_path}"'
else:
exec_line += f' {app_path}'
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}", "success")
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 DXVK configuration", "success")
# Update button text
self.update_switch_backend_button()
self.show_message(
"Switch to DXVK Complete",
f"Successfully switched to DXVK!\n\n"
f"• Removed vkd3d-proton DLLs\n"
f"• Removed vkd3d DLL overrides\n"
f"• Installed DXVK via winetricks\n"
f"• Reinstalled d3d12.dll and d3d12core.dll (required for compatibility)\n"
f"• Updated {updated_count} desktop entry/entries\n"
f"• All Affinity applications will now use DXVK for graphics acceleration",
"info"
)
except Exception as e:
self.log(f"Error switching to DXVK: {e}", "error")
self.show_message(
"Error",
f"An error occurred while switching to DXVK:\n\n{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()
# Get DXVK environment variables if AMD GPU is detected
dxvk_env = self.get_dxvk_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 = self.get_wine_path("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}'
if dxvk_env:
exec_line += f' {dxvk_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() if distro else "", distro.title() if distro else "Unknown")
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
# Check if Wine is already set up
wine = self.get_wine_path("wine")
if wine.exists():
self.log("Wine is already set up", "success")
else:
self.log("Wine is not set up. Use 'Setup Wine Environment' to install it.", "info")
# Show main menu (Wine setup can be done manually if needed)
self.update_progress(1.0)
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")
# Check if AMD GPU is detected and install additional dependencies based on distribution
if self.has_amd_gpu():
self.log("AMD GPU detected - installing additional OpenCL dependencies...", "info")
amd_deps = []
install_cmd = None
# Fedora
if self.distro == "fedora":
# Check if Fedora 43 - use different dependencies
if self.distro_version == "43":
amd_deps = ["mesa-opencl-icd", "ocl-icd", "rocm-opencl", "rocm-hip", "wine-opencl"]
self.log("Fedora 43 detected - installing Fedora 43 specific AMD OpenCL dependencies...", "info")
else:
# Use older dependencies for other Fedora versions
amd_deps = ["rocm-opencl", "apr", "apr-util", "zlib", "libxcrypt-compat", "libcurl", "libcurl-devel", "mesa-libGLU"]
install_cmd = ["sudo", "dnf", "install", "-y"] + amd_deps
# Arch-based distributions (Arch, CachyOS, EndeavourOS, XeroLinux)
elif self.distro in ["arch", "cachyos", "endeavouros", "xerolinux"]:
# Arch uses different package names than Fedora
amd_deps = ["opencl-mesa", "ocl-icd", "rocm-opencl-runtime", "rocm-hip", "wine-opencl"]
self.log(f"{self.format_distro_name()} detected - installing Arch-based AMD OpenCL dependencies...", "info")
install_cmd = ["sudo", "pacman", "-S", "--needed", "--noconfirm"] + amd_deps
# PikaOS (Ubuntu/Debian-based)
elif self.distro == "pikaos":
# PikaOS uses Debian/Ubuntu package names
amd_deps = ["mesa-opencl-icd", "ocl-icd-libopencl1", "rocm-opencl-runtime", "rocm-hip-runtime"]
self.log("PikaOS detected - installing Debian/Ubuntu-based AMD OpenCL dependencies...", "info")
install_cmd = ["sudo", "apt", "install", "-y"] + amd_deps
# Install dependencies if we have a command
if install_cmd and amd_deps:
self.log(f"Installing: {', '.join(amd_deps)}", "info")
success, stdout, stderr = self.run_command(install_cmd)
if success:
self.log("AMD OpenCL dependencies installed successfully", "success")
else:
self.log(f"Warning: Failed to install some AMD OpenCL dependencies: {stderr}", "warning")
self.log("OpenCL may still work, but some features might be limited", "warning")
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
# Ask user to choose Wine version
wine_version = self.show_question_dialog(
"Choose Wine Version",
"Which Wine version would you like to install?\n\n"
"• Wine 11.0 (Recommended) - ElementalWarrior Wine 11.0 with AMD GPU and OpenCL patches. Latest version with best compatibility and performance.\n"
"• Wine 10.10 - ElementalWarrior Wine 10.10 with AMD GPU and OpenCL patches. Previous stable version.\n"
"• Wine 9.14 (Legacy) - Legacy version with AMD GPU and OpenCL patches. Fallback option if you encounter issues with newer versions.\n\n"
"Note: You can switch versions later by running 'Setup Wine Environment' again.",
["Wine 11.0 (Recommended)", "Wine 10.10", "Wine 9.14 (Legacy)"]
)
if wine_version == "Wine 11.0 (Recommended)":
wine_version_choice = "11.0"
elif wine_version == "Wine 10.10":
wine_version_choice = "10.10"
elif wine_version == "Wine 9.14 (Legacy)":
wine_version_choice = "9.14"
else:
self.log("Wine setup cancelled", "warning")
return
self.setup_wine(wine_version_choice)
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 (without parent to avoid threading issues)
dialog = QDialog()
dialog.setWindowTitle("Select Affinity Application")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(400, int(screen_width * 0.9))
min_height = min(300, int(screen_height * 0.7))
default_width = min(500, int(screen_width * 0.85))
default_height = min(350, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 450
min_height = 320
default_width = 550
default_height = 380
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 450
min_height = 320
default_width = 550
default_height = 380
max_width = 800
max_height = 700
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
dialog.setStyleSheet(self.get_dialog_stylesheet())
# Main layout
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("Select Affinity Application")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_label = QLabel("Which Affinity application would you like to install?")
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
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):
app_frame = QFrame()
app_frame.setObjectName("optionFrame")
app_layout = QVBoxLayout(app_frame)
app_layout.setContentsMargins(12, 10, 12, 10)
radio = QRadioButton(app_name)
if app_code == "Add":
radio.setChecked(True)
radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
app_layout.addWidget(radio)
options_layout.addWidget(app_frame)
button_group.addButton(radio, idx)
radio_buttons[idx] = app_code
main_layout.addWidget(scroll_area, 1)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
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 = self.get_wine_path("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 (without parent to avoid threading issues)
dialog = QDialog()
dialog.setWindowTitle(f"Install {display_name}")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(400, int(screen_width * 0.9))
min_height = min(280, int(screen_height * 0.7))
default_width = min(500, int(screen_width * 0.85))
default_height = min(320, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 450
min_height = 300
default_width = 550
default_height = 350
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 450
min_height = 300
default_width = 550
default_height = 350
max_width = 800
max_height = 600
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
dialog.setStyleSheet(self.get_dialog_stylesheet())
# Main layout
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel(f"Install {display_name}")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_label = QLabel(f"How would you like to get the {display_name} installer?")
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
button_group = QButtonGroup()
# Download option
download_frame = QFrame()
download_frame.setObjectName("optionFrame")
download_layout = QVBoxLayout(download_frame)
download_layout.setContentsMargins(12, 10, 12, 10)
download_radio = QRadioButton("Download from Affinity Studio (automatic)")
download_radio.setChecked(True)
download_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
download_layout.addWidget(download_radio)
options_layout.addWidget(download_frame)
button_group.addButton(download_radio, 0)
# Custom option
custom_frame = QFrame()
custom_frame.setObjectName("optionFrame")
custom_layout = QVBoxLayout(custom_frame)
custom_layout.setContentsMargins(12, 10, 12, 10)
custom_radio = QRadioButton("Provide my own installer file (.exe)")
custom_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
custom_layout.addWidget(custom_radio)
options_layout.addWidget(custom_frame)
button_group.addButton(custom_radio, 1)
main_layout.addWidget(scroll_area, 1)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
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 to .AffinityLinux/Installer/ directory
download_dir = Path(self.directory) / "Installer"
download_dir.mkdir(parents=True, exist_ok=True)
installer_path = download_dir / "Affinity-x64.exe"
self.log(f"Downloading to: {installer_path}", "info")
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", "bazzite"]:
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", "bazzite"]:
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", "bazzite"]:
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", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"cachyos": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"endeavouros": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"xerolinux": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"manjaro": ["sudo", "pacman", "-S", "--needed", "--noconfirm", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"fedora": ["sudo", "dnf", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "p7zip-plugins", "tar", "jq", "zstd", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"nobara": ["sudo", "dnf", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "p7zip-plugins", "tar", "jq", "zstd", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"opensuse-tumbleweed": ["sudo", "zypper", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0", "dotnet-sdk-10.0"],
"opensuse-leap": ["sudo", "zypper", "install", "-y", "wine", "winetricks", "wget", "curl", "p7zip", "tar", "jq", "zstd", "dotnet-sdk-8.0", "dotnet-sdk-10.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, winetricks install = 8 steps
total_steps = 8
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")
# 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
# Download GPG key to temporary file first (handles binary data correctly)
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
tmp_key_path = tmp_file.name
try:
# Download the key in binary mode
success, _, _ = self.run_command(["wget", "-O", tmp_key_path, "https://dl.winehq.org/wine-builds/winehq.key"])
if not success:
self.log("Failed to download GPG key", "error")
os.unlink(tmp_key_path)
return False
# Read the key file in binary mode
with open(tmp_key_path, 'rb') as key_file:
key_data = key_file.read()
# Clean up temp file
os.unlink(tmp_key_path)
# Run GPG command with sudo, passing binary key data
gpg_proc = subprocess.Popen(
["sudo", "-S", "gpg", "--dearmor", "-o", "/etc/apt/keyrings/winehq-archive.key", "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Send password first (as bytes), then the key data (binary)
gpg_input = f"{self.sudo_password}\n".encode() + key_data
gpg_stdout, gpg_stderr = gpg_proc.communicate(input=gpg_input)
if gpg_proc.returncode == 0:
self.log("WineHQ GPG key added", "success")
else:
error_msg = gpg_stderr.decode('utf-8', errors='ignore') if gpg_stderr else "Unknown error"
self.log(f"Failed to add GPG key: {error_msg}", "error")
return False
except Exception as e:
# Clean up temp file on error
import os as _os
if _os.path.exists(tmp_key_path):
try:
_os.unlink(tmp_key_path)
except:
pass
self.log(f"Failed to add GPG key: {str(e)}", "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")
# Always use Debian testing repository for the newest WineHQ packages
# This ensures we get the latest WineHQ versions without needing to update
# the script every Debian release. Debian testing codename is currently "forky"
codename = "forky" # Debian testing
self.log(f"Using Debian testing (forky) repository for latest WineHQ packages", "info")
# Remove existing WineHQ repository files first to avoid conflicts
repo_pattern = Path("/etc/apt/sources.list.d/")
for repo_file in repo_pattern.glob("winehq-*.sources"):
self.run_command(["sudo", "rm", "-f", str(repo_file)], check=False)
# Add the repository using the detected codename
# Use -NP flags: -N for timestamping, -P for directory
success, _, _ = self.run_command([
"sudo", "wget", "-NP", "/etc/apt/sources.list.d/",
f"https://dl.winehq.org/wine-builds/debian/dists/{codename}/winehq-{codename}.sources"
])
if not success:
self.log(f"Failed to add repository for {codename}", "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", "wget", "curl", "p7zip-full", "tar", "jq", "zstd"
])
if not success:
self.log("Failed to install remaining dependencies", "error")
return False
# Install winetricks from source
self.log("Installing winetricks from source...", "info")
success, _, _ = self.run_command([
"git", "clone", "https://github.com/Winetricks/winetricks"
])
if not success:
self.log("Failed to clone winetricks repository", "error")
return False
# Change to winetricks directory and install
os.chdir("winetricks")
success, _, _ = self.run_command(["sudo", "make", "install"])
if not success:
self.log("Failed to install winetricks", "error")
return False
# Go back to original directory
os.chdir("..")
# Clean up
shutil.rmtree("winetricks")
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")
# 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
# Download GPG key to temporary file first (handles binary data correctly)
with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
tmp_key_path = tmp_file.name
try:
# Download the key in binary mode
success, _, _ = self.run_command(["wget", "-O", tmp_key_path, "https://dl.winehq.org/wine-builds/winehq.key"])
if not success:
self.log("Failed to download GPG key", "error")
os.unlink(tmp_key_path)
return False
# Read the key file in binary mode
with open(tmp_key_path, 'rb') as key_file:
key_data = key_file.read()
# Clean up temp file
os.unlink(tmp_key_path)
# Run GPG command with sudo, passing binary key data
gpg_proc = subprocess.Popen(
["sudo", "-S", "gpg", "--dearmor", "-o", "/etc/apt/keyrings/winehq-archive.key", "-"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE
)
# Send password first (as bytes), then the key data (binary)
gpg_input = f"{self.sudo_password}\n".encode() + key_data
gpg_stdout, gpg_stderr = gpg_proc.communicate(input=gpg_input)
if gpg_proc.returncode == 0:
self.log("WineHQ GPG key added", "success")
else:
error_msg = gpg_stderr.decode('utf-8', errors='ignore') if gpg_stderr else "Unknown error"
self.log(f"Failed to add GPG key: {error_msg}", "error")
return False
except Exception as e:
# Clean up temp file on error
if os.path.exists(tmp_key_path):
try:
os.unlink(tmp_key_path)
except:
pass
self.log(f"Failed to add GPG key: {str(e)}", "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, wine_version="11.0"):
"""Setup Wine environment - installs custom Wine 9.14, 10.10, or 11.0 with AMD GPU and OpenCL patches
Args:
wine_version: "9.14" for Wine 9.14 (legacy), "10.10" for Wine 10.10, or "11.0" for Wine 11.0 (recommended)
"""
self.start_operation("Setting up Wine environment")
try:
# Check if cancelled at start
if self.check_cancelled():
return False
# First check that system Wine is available (needed for installation)
system_wine = shutil.which("wine")
if not system_wine:
self.log("System Wine not found. System Wine is required for installation:", "error")
self.log(" Ubuntu/Debian: sudo apt install wine", "info")
self.log(" Fedora: sudo dnf install wine", "info")
self.log(" Arch: sudo pacman -S wine", "info")
self.update_progress_text("Ready")
return False
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Wine Binary Setup", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Get Wine version configuration
config = self._get_wine_version_config(wine_version)
wine_url = config["wine_url"]
wine_file_name = config["wine_file_name"]
wine_dir_name = config["wine_dir_name"]
wine_dir_pattern = config["wine_dir_pattern"]
archive_format = config["archive_format"]
wine_display_name = config["wine_display_name"]
self.log(f"Installing: {wine_display_name}", "info")
# 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_file = Path(self.directory) / wine_file_name
self.update_progress_text(f"Downloading {wine_display_name}...")
self.update_progress(0.10)
self.log(f"Downloading {wine_display_name}...", "info")
if not self.download_file(wine_url, str(wine_file), f"{wine_display_name} binaries"):
self.log(f"Failed to download {wine_display_name}", "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:
if archive_format == "gz":
with tarfile.open(wine_file, "r:gz") as tar:
tar.extractall(self.directory, filter='data')
elif archive_format == "xz":
try:
import lzma
with lzma.open(wine_file, 'rb') as xz_file:
with tarfile.open(fileobj=xz_file, mode='r') as tar:
tar.extractall(self.directory, filter='data')
except ImportError:
if not self.check_command("xz") and not self.check_command("unxz"):
self.log("xz or unxz is required to extract Wine archive. Please install xz.", "error")
self.update_progress_text("Ready")
return False
tar_file = wine_file.with_suffix('.tar')
xz_cmd = "xz" if self.check_command("xz") else "unxz"
success, _, _ = self.run_command([xz_cmd, "-d", "-k", str(wine_file)], check=True)
if not success:
self.log("Failed to decompress Wine archive", "error")
self.update_progress_text("Ready")
return False
with tarfile.open(tar_file, "r") as tar:
tar.extractall(self.directory, filter='data')
tar_file.unlink()
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(wine_dir_pattern), None)
if wine_dir and wine_dir != Path(self.directory) / wine_dir_name:
target = Path(self.directory) / wine_dir_name
if target.exists() or target.is_symlink():
if target.is_symlink():
target.unlink()
elif target.is_dir():
shutil.rmtree(target)
target.symlink_to(wine_dir)
self.log("Wine symlink created", "success")
# Verify Wine binary
self.update_progress(0.60)
wine_binary = Path(self.directory) / wine_dir_name / "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 (only needed for Wine 9.14 and 10.10, not 11.0+)
if wine_version in ["9.14", "10.10"]:
self.update_progress_text("Setting up Windows Metadata...")
self.update_progress(0.70)
self.setup_winmetadata()
else:
self.log("Skipping WinMetadata setup for Wine 11.0+ (not needed)", "info")
if self.check_cancelled():
return False
# Cache all other Wine versions in background (for future switching)
self.update_progress_text("Caching other Wine versions...")
self.update_progress(0.72)
self._download_all_wine_versions_to_cache(wine_version)
if self.check_cancelled():
return False
if self.check_cancelled():
return False
# Setup vkd3d-proton (only if OpenCL is enabled and not AMD GPU)
if self.is_opencl_enabled():
if self.has_amd_gpu():
self.update_progress_text("AMD GPU detected - installing DXVK via winetricks...")
self.update_progress(0.80)
self.log("AMD GPU detected - installing DXVK via winetricks", "info")
self.install_dxvk_dlls()
self.log("Installing d3d12 DLLs for compatibility...", "info")
self.install_d3d12_dlls()
elif self.has_nvidia_gpu():
# Ask NVIDIA users to choose between DXVK and vkd3d
preference = self.ask_nvidia_dxvk_vkd3d_choice()
if preference == "dxvk":
self.update_progress_text("NVIDIA GPU with DXVK preference - installing d3d12 DLLs...")
self.update_progress(0.80)
self.log("NVIDIA GPU with DXVK preference - installing d3d12 DLLs and setting up DLL overrides", "info")
self.install_d3d12_dlls()
else:
self.update_progress_text("Setting up vkd3d-proton for OpenCL...")
self.update_progress(0.80)
self.setup_vkd3d()
else:
self.update_progress_text("Setting up vkd3d-proton for OpenCL...")
self.update_progress(0.80)
self.setup_vkd3d()
else:
self.update_progress_text("Installing d3d12 DLLs...")
self.update_progress(0.80)
self.log("OpenCL support is disabled, but installing d3d12 DLLs for compatibility", "info")
self.install_d3d12_dlls()
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 _download_and_extract_winmetadata(self, extract_to_dir):
"""Download WinMetadata.tar.xz and extract it to the specified directory"""
try:
# Create temp directory for download
temp_dir = Path(self.directory) / ".temp_winmetadata"
if temp_dir.exists():
shutil.rmtree(temp_dir)
temp_dir.mkdir(exist_ok=True)
winmetadata_url = "https://github.com/ryzendew/AffinityOnLinux/releases/download/10.4-Wine-Affinity/WinMetadata.tar.xz"
winmetadata_file = temp_dir / "WinMetadata.tar.xz"
self.log("Downloading WinMetadata...", "info")
if not self.download_file(winmetadata_url, str(winmetadata_file), "WinMetadata"):
self.log("Failed to download WinMetadata", "error")
return False
self.log("Extracting WinMetadata...", "info")
self.update_progress_text("Extracting Windows Metadata...")
# Extract tar.xz file
try:
import lzma
with lzma.open(winmetadata_file, 'rb') as xz_file:
with tarfile.open(fileobj=xz_file, mode='r') as tar:
tar.extractall(extract_to_dir, filter='data')
except ImportError:
# Fallback to using xz command if lzma module is not available
if not self.check_command("xz") and not self.check_command("unxz"):
self.log("xz or unxz is required to extract WinMetadata. Please install xz.", "error")
return False
tar_file = winmetadata_file.with_suffix('.tar')
xz_cmd = "xz" if self.check_command("xz") else "unxz"
success, _, _ = self.run_command([xz_cmd, "-d", "-k", str(winmetadata_file)], check=True)
if not success:
self.log("Failed to decompress WinMetadata archive", "error")
return False
with tarfile.open(tar_file, "r") as tar:
tar.extractall(extract_to_dir, filter='data')
tar_file.unlink()
# Clean up temp directory
try:
shutil.rmtree(temp_dir)
except Exception:
pass
self.log("WinMetadata downloaded and extracted", "success")
return True
except Exception as e:
self.log(f"Failed to download and extract WinMetadata: {e}", "error")
return False
def _download_wintypes_dll(self, output_path):
"""Download wintypes.dll to the specified path"""
try:
wintypes_url = "https://github.com/ElementalWarrior/wine-wintypes.dll-for-affinity/raw/refs/heads/master/wintypes_shim.dll.so"
self.log("Downloading wintypes.dll...", "info")
if not self.download_file(wintypes_url, str(output_path), "wintypes.dll"):
self.log("Failed to download wintypes.dll", "error")
return False
self.log("wintypes.dll downloaded", "success")
return True
except Exception as e:
self.log(f"Failed to download wintypes.dll: {e}", "error")
return False
def setup_winmetadata(self):
"""Download and install WinMetadata to system32"""
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Windows Metadata Installation", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
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 and installing Windows metadata...", "info")
try:
winmetadata_dest = system32_dir / "WinMetadata"
# Remove existing WinMetadata if it exists
if winmetadata_dest.exists():
shutil.rmtree(winmetadata_dest)
self.log("Removed existing WinMetadata folder", "info")
# Download and extract WinMetadata
if not self._download_and_extract_winmetadata(system32_dir):
self.log("WinMetadata will not be installed", "warning")
return
# Verify WinMetadata was extracted
if not winmetadata_dest.exists():
self.log("WinMetadata extraction failed - folder not found", "error")
return
self.log("WinMetadata installed to system32", "success")
except Exception as e:
self.log(f"Failed to install WinMetadata: {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 = self.get_wine_path("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")
# Ensure system32 directory exists
system32_dir.mkdir(parents=True, exist_ok=True)
# Reinstall WinMetadata by downloading and extracting (only for Wine < 11.0)
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
self.log("Installing fresh WinMetadata...", "info")
self.setup_winmetadata()
# Set up wintypes.dll override
self.log("Setting up wintypes.dll override...", "info")
self.setup_wintypes_dll_override()
# Copy wintypes.dll for all installed Affinity apps (v2 and v3)
self.log("Copying wintypes.dll for installed Affinity apps...", "info")
self.copy_wintypes_dll_for_all_apps()
else:
self.log("Skipping WinMetadata and wintypes.dll setup for Wine 11.0+ (not needed)", "info")
self.log("\n✓ WinMetadata reinstallation completed!", "success")
def get_latest_vkd3d_version(self):
"""Get the latest vkd3d-proton version from GitHub releases API
Returns:
str: Latest version tag (e.g., "3.0a") or None if check fails
"""
try:
api_url = "https://api.github.com/repos/HansKristian-Work/vkd3d-proton/releases/latest"
self.log("Checking for latest vkd3d-proton version...", "info")
request = urllib.request.Request(api_url)
request.add_header("User-Agent", "AffinityLinuxInstaller")
with urllib.request.urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode())
latest_version = data.get("tag_name", "").lstrip("v") # Remove 'v' prefix if present
if latest_version:
self.log(f"Latest vkd3d-proton version: {latest_version}", "info")
return latest_version
else:
self.log("Could not determine latest version from API", "warning")
return None
except urllib.error.URLError as e:
self.log(f"Failed to check for latest vkd3d-proton version: {e}", "warning")
return None
except json.JSONDecodeError as e:
self.log(f"Failed to parse GitHub API response: {e}", "warning")
return None
except Exception as e:
self.log(f"Error checking for latest vkd3d-proton version: {e}", "warning")
return None
def get_installed_vkd3d_version(self):
"""Get the currently installed vkd3d-proton version from cache
Returns:
str: Installed version or None if not found
"""
version_file = Path(self.directory) / "dxvk" / ".vkd3d_version"
if version_file.exists():
try:
return version_file.read_text().strip()
except Exception:
return None
return None
def set_installed_vkd3d_version(self, version):
"""Store the installed vkd3d-proton version
Args:
version: Version string to store
"""
cache_dir = Path(self.directory) / "dxvk"
cache_dir.mkdir(parents=True, exist_ok=True)
version_file = cache_dir / ".vkd3d_version"
try:
version_file.write_text(version)
except Exception as e:
self.log(f"Failed to save vkd3d version: {e}", "warning")
def get_latest_dxvk_version(self):
"""Get the latest DXVK version from GitHub releases API (normal version, not steamrt-sniper)
Returns:
str: Latest version tag (e.g., "2.3") or None if check fails
"""
try:
api_url = "https://api.github.com/repos/doitsujin/dxvk/releases/latest"
self.log("Checking for latest DXVK version...", "info")
request = urllib.request.Request(api_url)
request.add_header("User-Agent", "AffinityLinuxInstaller")
with urllib.request.urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode())
latest_version = data.get("tag_name", "").lstrip("v") # Remove 'v' prefix if present
if latest_version:
self.log(f"Latest DXVK version: {latest_version}", "info")
return latest_version
else:
self.log("Could not determine latest version from API", "warning")
return None
except urllib.error.URLError as e:
self.log(f"Failed to check for latest DXVK version: {e}", "warning")
return None
except json.JSONDecodeError as e:
self.log(f"Failed to parse GitHub API response: {e}", "warning")
return None
except Exception as e:
self.log(f"Error checking for latest DXVK version: {e}", "warning")
return None
def get_installed_dxvk_version(self):
"""Check if DXVK is installed via winetricks
Returns:
str: "winetricks" if installed, None if not found
"""
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
wine = self.get_wine_path("wine")
dxvk_dlls = ["d3d8", "d3d9", "d3d11", "dxgi"]
for dll in dxvk_dlls:
success, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll],
check=False,
env=env,
capture=True
)
if success and "native" in stdout:
return "winetricks"
return None
def set_installed_dxvk_version(self, version):
"""Mark DXVK as installed (for compatibility)
Args:
version: Version string (typically "winetricks")
"""
pass
def install_dxvk_dlls(self):
"""Install DXVK using winetricks and ensure DLL overrides are set correctly
DXVK is installed via winetricks which should handle DLL overrides, but we verify
and set them up if needed to ensure proper functionality.
"""
self.log("Installing DXVK via winetricks...", "info")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
env["WINETRICKS_GUI"] = "0"
env["DISPLAY"] = env.get("DISPLAY", ":0")
env = self.get_winetricks_env_with_tkg(env)
success = self.run_command_streaming(
["winetricks", "--unattended", "--verbose", "--force", "--no-isolate", "--optout", "dxvk"],
env=env,
progress_callback=None
)
# Always check for 64-bit DLLs regardless of winetricks success
# (winetricks may fail but still install 32-bit DLLs, or may fail completely)
system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32"
dxvk_dll_names = ["d3d8.dll", "d3d9.dll", "d3d10.dll", "d3d10_1.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"]
missing_64bit = [dll for dll in dxvk_dll_names if not (system32_dir / dll).exists()]
if missing_64bit:
self.log("64-bit DXVK DLLs missing in system32, downloading DXVK release...", "info")
latest_version = self.get_latest_dxvk_version()
if not latest_version:
latest_version = "2.3"
dxvk_url = f"https://github.com/doitsujin/dxvk/releases/download/v{latest_version}/dxvk-{latest_version}.tar.gz"
dxvk_file = Path(self.directory) / f"dxvk-{latest_version}.tar.gz"
if self.download_file(dxvk_url, str(dxvk_file), "DXVK"):
try:
import tarfile
with tarfile.open(dxvk_file, "r:gz") as tar:
extracted_count = 0
for member in tar.getmembers():
if member.name.startswith(f"dxvk-{latest_version}/x64/") and member.name.endswith(".dll"):
dll_name = Path(member.name).name
if dll_name in missing_64bit:
member.name = dll_name
tar.extract(member, system32_dir, filter='data')
extracted_count += 1
self.log(f"Extracted 64-bit {dll_name} to system32", "info")
if extracted_count > 0:
self.log(f"Extracted {extracted_count} 64-bit DXVK DLL(s) to system32", "success")
else:
self.log("No 64-bit DLLs found in DXVK archive", "warning")
except Exception as e:
self.log(f"Failed to extract DXVK: {e}", "warning")
finally:
if dxvk_file.exists():
dxvk_file.unlink()
else:
self.log("Failed to download DXVK, DLLs may not work correctly", "warning")
else:
self.log("64-bit DXVK DLLs verified in system32", "success")
if success:
self.log("DXVK installed via winetricks, verifying installation...", "info")
else:
self.log("Winetricks installation failed, but continuing with manual DXVK setup...", "warning")
# Verify and set up DLL overrides (regardless of winetricks success)
wine = self.get_wine_path("wine")
dxvk_dlls = ["d3d8", "d3d9", "d3d10", "d3d10_1", "d3d10core", "d3d11", "dxgi"]
override_count = 0
for dll in dxvk_dlls:
success_check, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll],
check=False,
env=env,
capture=True
)
if success_check and "native" in stdout:
override_count += 1
if override_count < len(dxvk_dlls):
self.log("Setting up DLL overrides for DXVK...", "info")
reg_file = Path(self.directory) / "dxvk_overrides.reg"
with open(reg_file, "w") as f:
f.write("REGEDIT4\n")
f.write("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]\n")
for dll in dxvk_dlls:
f.write(f'"{dll}"="native,builtin"\n')
regedit = self.get_wine_path("regedit")
reg_success, _, stderr = self.run_command([str(regedit), str(reg_file)], check=False, env=env, capture=True)
reg_file.unlink()
if reg_success:
self.log("DXVK DLL overrides configured", "success")
else:
self.log(f"Warning: Could not configure DLL overrides: {stderr}", "warning")
else:
self.log(f"DXVK DLL overrides verified ({override_count} DLLs)", "success")
self.set_installed_dxvk_version("winetricks")
def install_d3d12_dlls(self):
"""Install d3d12.dll and d3d12core.dll from vkd3d-proton and set up DLL overrides"""
self.log("Installing d3d12.dll and d3d12core.dll...", "info")
# Get latest version or use default
latest_version = self.get_latest_vkd3d_version()
if not latest_version:
# Fallback to current latest known version
latest_version = "3.0a"
self.log(f"Using fallback version: {latest_version}", "info")
# Check if we need to update
installed_version = self.get_installed_vkd3d_version()
if installed_version and installed_version == latest_version:
self.log(f"vkd3d-proton {latest_version} is already installed", "info")
elif installed_version:
self.log(f"Updating vkd3d-proton from {installed_version} to {latest_version}", "info")
# Clear old cache if version changed
cache_dir = Path(self.directory) / "dxvk"
old_cached_dir = cache_dir / f"vkd3d-proton-{installed_version}"
if old_cached_dir.exists():
try:
shutil.rmtree(old_cached_dir)
self.log(f"Removed old cached version {installed_version}", "info")
except Exception as e:
self.log(f"Warning: Could not remove old cache: {e}", "warning")
vkd3d_version = latest_version
vkd3d_url = f"https://github.com/HansKristian-Work/vkd3d-proton/releases/download/v{vkd3d_version}/vkd3d-proton-{vkd3d_version}.tar.zst"
vkd3d_file_name = f"vkd3d-proton-{vkd3d_version}.tar.zst"
cache_dir = Path(self.directory) / "dxvk"
cache_dir.mkdir(parents=True, exist_ok=True)
cached_vkd3d_file = cache_dir / vkd3d_file_name
cached_vkd3d_dir = cache_dir / f"vkd3d-proton-{vkd3d_version}"
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
vkd3d_temp.mkdir(exist_ok=True)
# Check if DLLs already exist
wine_lib_dir = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
if wine_lib_dir.exists() and (wine_lib_dir / "d3d12.dll").exists() and (wine_lib_dir / "d3d12core.dll").exists():
self.log("d3d12 DLLs already installed", "info")
self.setup_d3d12_overrides()
return
# Check cache first
vkd3d_dir = None
if cached_vkd3d_dir.exists():
self.log("Using cached vkd3d-proton...", "info")
vkd3d_dir = cached_vkd3d_dir
else:
# Download vkd3d-proton
self.log("Downloading vkd3d-proton for d3d12 DLLs...", "info")
vkd3d_file = Path(self.directory) / vkd3d_file_name
if not self.download_file(vkd3d_url, str(vkd3d_file), "vkd3d-proton"):
self.log("Failed to download vkd3d-proton", "error")
return
# Cache the downloaded file
shutil.copy2(vkd3d_file, cached_vkd3d_file)
self.log("Cached vkd3d-proton archive", "success")
# Extract 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()
# Find extracted directory and cache it
vkd3d_dir = next(Path(self.directory).glob("vkd3d-proton-*"), None)
if vkd3d_dir:
# Cache the extracted directory
shutil.copytree(vkd3d_dir, cached_vkd3d_dir)
self.log("Cached vkd3d-proton directory", "success")
else:
self.log("Failed to find extracted vkd3d-proton directory", "error")
return
# Copy DLLs
if vkd3d_dir:
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"Installed {dll}", "success")
break
# Only remove if it's not the cached version
if vkd3d_dir != cached_vkd3d_dir:
shutil.rmtree(vkd3d_dir)
# Store installed version
self.set_installed_vkd3d_version(vkd3d_version)
self.log(f"d3d12 DLLs installed (vkd3d-proton {vkd3d_version})", "success")
# Set up DLL overrides
self.setup_d3d12_overrides()
def setup_d3d12_overrides(self):
"""Set up DLL overrides for d3d12.dll and d3d12core.dll"""
self.log("Setting up DLL overrides for d3d12...", "info")
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 = self.get_wine_path("regedit")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
success, _, stderr = self.run_command([str(regedit), str(reg_file)], check=False, env=env, capture=True)
reg_file.unlink()
if success:
self.log("DLL overrides configured for d3d12", "success")
else:
self.log(f"Warning: Could not configure DLL overrides: {stderr}", "warning")
def setup_dxvk_overrides(self):
"""
Set up DLL overrides for DXVK in Wine registry
DXVK is installed via winetricks which automatically sets up DLL overrides.
This function verifies the installation and ensures overrides are correct.
"""
self.log("Verifying DXVK installation via winetricks...", "info")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
env["WINETRICKS_GUI"] = "0"
env["DISPLAY"] = env.get("DISPLAY", ":0")
env = self.get_winetricks_env_with_tkg(env)
wine = self.get_wine_path("wine")
dxvk_dlls = ["d3d8", "d3d9", "d3d10", "d3d10_1", "d3d10core", "d3d11", "dxgi"]
override_count = 0
for dll in dxvk_dlls:
success, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll],
check=False,
env=env,
capture=True
)
if success and "native" in stdout:
override_count += 1
if override_count > 0:
self.log(f"DXVK DLL overrides verified ({override_count} DLLs)", "success")
else:
self.log("DXVK DLL overrides not found - winetricks should have set them up", "warning")
self.log("Installing DXVK via winetricks to set up overrides...", "info")
self.install_dxvk_dlls()
def remove_dxvk_overrides(self):
"""Remove DXVK via winetricks and clean up DLL overrides"""
self.log("Removing DXVK via winetricks...", "info")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
env["WINETRICKS_GUI"] = "0"
env["DISPLAY"] = env.get("DISPLAY", ":0")
env = self.get_winetricks_env_with_tkg(env)
wine = self.get_wine_path("wine")
dxvk_dlls = ["d3d8", "d3d9", "d3d10", "d3d10_1", "d3d10core", "d3d11", "dxgi"]
removed_count = 0
for dll in dxvk_dlls:
success, _, _ = self.run_command(
[str(wine), "reg", "delete", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll, "/f"],
check=False,
env=env,
capture=True
)
if success:
removed_count += 1
if removed_count > 0:
self.log(f"Removed {removed_count} DXVK DLL override(s)", "success")
else:
self.log("No DXVK DLL overrides found to remove", "info")
self.remove_dxvk_dlls_from_system32()
cache_dir = Path(self.directory) / "dxvk"
if cache_dir.exists():
try:
shutil.rmtree(cache_dir)
self.log("Removed DXVK cache directory", "success")
except Exception as e:
self.log(f"Warning: Could not remove DXVK cache: {e}", "warning")
def remove_dxvk_dlls_from_system32(self):
"""Remove DXVK DLLs from system32 directory"""
self.log("Removing DXVK DLLs from system32...", "info")
system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32"
dxvk_dlls = ["d3d8.dll", "d3d9.dll", "d3d10core.dll", "d3d11.dll", "dxgi.dll"]
removed_count = 0
for dll in dxvk_dlls:
dll_path = system32_dir / dll
if dll_path.exists():
try:
dll_path.unlink()
self.log(f"Removed {dll} from system32", "success")
removed_count += 1
except Exception as e:
self.log(f"Warning: Could not remove {dll} from system32: {e}", "warning")
if removed_count > 0:
self.log(f"Removed {removed_count} DXVK DLL(s) from system32", "success")
else:
self.log("No DXVK DLLs found in system32 to remove", "info")
def remove_d3d12_overrides(self):
"""Remove DLL overrides for vkd3d (d3d12, d3d12core)"""
self.log("Removing DLL overrides for vkd3d...", "info")
wine = self.get_wine_path("wine")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
vkd3d_dlls = ["d3d12", "d3d12core"]
removed_count = 0
for dll in vkd3d_dlls:
success, _, _ = self.run_command(
[str(wine), "reg", "delete", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll, "/f"],
check=False,
env=env,
capture=True
)
if success:
removed_count += 1
if removed_count > 0:
self.log(f"Removed {removed_count} vkd3d DLL override(s)", "success")
else:
self.log("No vkd3d DLL overrides found to remove", "info")
def setup_vkd3d(self):
"""Setup vkd3d-proton for OpenCL"""
# Check NVIDIA GPU preference
if self.has_nvidia_gpu():
preference = self.get_dxvk_vkd3d_preference()
if preference == "dxvk":
self.log("NVIDIA GPU with DXVK preference - skipping vkd3d-proton installation", "info")
# Still install d3d12 DLLs and overrides
self.install_d3d12_dlls()
return
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("OpenCL Support Setup", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Get latest version or use default
latest_version = self.get_latest_vkd3d_version()
if not latest_version:
# Fallback to current latest known version
latest_version = "3.0a"
self.log(f"Using fallback version: {latest_version}", "info")
# Check if we need to update
installed_version = self.get_installed_vkd3d_version()
if installed_version and installed_version == latest_version:
self.log(f"vkd3d-proton {latest_version} is already installed", "info")
elif installed_version:
self.log(f"Updating vkd3d-proton from {installed_version} to {latest_version}", "info")
vkd3d_version = latest_version
vkd3d_url = f"https://github.com/HansKristian-Work/vkd3d-proton/releases/download/v{vkd3d_version}/vkd3d-proton-{vkd3d_version}.tar.zst"
vkd3d_file = Path(self.directory) / f"vkd3d-proton-{vkd3d_version}.tar.zst"
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
vkd3d_temp.mkdir(exist_ok=True)
self.update_progress_text("Downloading vkd3d-proton...")
self.log(f"Downloading vkd3d-proton {vkd3d_version}...", "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 = self.get_wine_dir() / "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)
# Store installed version
self.set_installed_vkd3d_version(vkd3d_version)
self.log(f"vkd3d-proton setup completed (version {vkd3d_version})", "success")
# Set up DLL overrides
self.setup_d3d12_overrides()
def configure_wine(self):
"""Configure Wine with winetricks"""
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Wine Configuration", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Ensure wine-tkg is available for winetricks
self.log("Setting up wine-tkg for winetricks...", "info")
sys.stderr.write("\n[WINE-TKG] Calling ensure_wine_tkg() for winetricks...\n")
sys.stderr.flush()
wine_tkg_result = self.ensure_wine_tkg()
sys.stderr.write(f"[WINE-TKG] ensure_wine_tkg() returned: {wine_tkg_result}\n")
sys.stderr.flush()
if not wine_tkg_result:
error_msg = "Failed to setup wine-tkg, continuing with system wine"
sys.stderr.write(f"[WINE-TKG] WARNING: {error_msg}\n")
sys.stderr.flush()
self.log(error_msg, "warning")
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
# Use wine-tkg for winetricks if available
env = self.get_winetricks_env_with_tkg(env)
wine_cfg = self.get_wine_path("winecfg")
components = [
"dotnet35sp1", "dotnet48", "corefonts", "vcrun2022",
"msxml3", "msxml6", "tahoma", "renderer=vulkan", "crypt32"
]
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 = self.get_wine_path("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")
# Ask user to choose Wine version
wine_version = self.show_question_dialog(
"Choose Wine Version",
"Which Wine version would you like to install?\n\n"
"• Wine 11.0 (Recommended) - ElementalWarrior Wine 11.0 with AMD GPU and OpenCL patches. Latest version with best compatibility and performance.\n"
"• Wine 10.10 - ElementalWarrior Wine 10.10 with AMD GPU and OpenCL patches. Previous stable version.\n"
"• Wine 9.14 (Legacy) - Legacy version with AMD GPU and OpenCL patches. Fallback option if you encounter issues with newer versions.\n\n"
"Note: You can switch versions later by running this setup again.",
["Wine 11.0 (Recommended)", "Wine 10.10", "Wine 9.14 (Legacy)"]
)
if wine_version == "Wine 11.0 (Recommended)":
wine_version_choice = "11.0"
elif wine_version == "Wine 10.10":
wine_version_choice = "10.10"
elif wine_version == "Wine 9.14 (Legacy)":
wine_version_choice = "9.14"
else:
self.log("Wine setup cancelled", "warning")
return
threading.Thread(target=self.setup_wine, args=(wine_version_choice,), daemon=True).start()
def _get_wine_version_config(self, wine_version):
"""Get Wine version configuration (URL, filename, etc.)
Args:
wine_version: "9.14", "10.10", or "11.0"
Returns:
dict with wine_url, wine_file_name, wine_dir_name, wine_dir_pattern, archive_format, wine_display_name
"""
if wine_version == "9.14":
return {
"wine_url": "https://github.com/seapear/AffinityOnLinux/releases/download/Legacy/ElementalWarriorWine-x86_64.tar.gz",
"wine_file_name": "ElementalWarriorWine-x86_64.tar.gz",
"wine_dir_name": "ElementalWarriorWine",
"wine_dir_pattern": "ElementalWarriorWine*",
"archive_format": "gz",
"wine_display_name": "Wine 9.14 (Legacy - with AMD GPU and OpenCL patches)"
}
elif wine_version == "10.10":
return {
"wine_url": "https://github.com/ryzendew/Affinity-Wine-Builder/releases/download/10.10/ElementalWarrior-wine-10.10.tar.xz",
"wine_file_name": "ElementalWarrior-wine-10.10.tar.xz",
"wine_dir_name": "ElementalWarriorWine",
"wine_dir_pattern": "ElementalWarrior-wine-10.10*",
"archive_format": "xz",
"wine_display_name": "Wine 10.10 (with AMD GPU and OpenCL patches)"
}
else: # Default to 11.0
return {
"wine_url": "https://github.com/ryzendew/Affinity-Wine-Builder/releases/download/11.0/ElementalWarrior-wine-11.0.tar.xz",
"wine_file_name": "ElementalWarrior-wine-11.0.tar.xz",
"wine_dir_name": "ElementalWarriorWine",
"wine_dir_pattern": "ElementalWarrior-wine-11.0*",
"archive_format": "xz",
"wine_display_name": "Wine 11.0 (Latest - with AMD GPU and OpenCL patches)"
}
def _download_wine_to_cache(self, wine_version, cache_dir):
"""Download a Wine version to cache directory
Args:
wine_version: "9.14", "10.10", or "11.0"
cache_dir: Path to cache directory
Returns:
True if successful, False otherwise
"""
config = self._get_wine_version_config(wine_version)
wine_file = cache_dir / config["wine_file_name"]
wine_dir = cache_dir / wine_version
# Check if already cached
if wine_dir.exists() and (wine_dir / "bin" / "wine").exists():
self.log(f"{config['wine_display_name']} already cached, skipping download", "info")
return True
# Download Wine binary
self.log(f"Caching {config['wine_display_name']}...", "info")
if not self.download_file(config["wine_url"], str(wine_file), f"{config['wine_display_name']} binaries"):
self.log(f"Failed to cache {config['wine_display_name']}", "warning")
return False
if self.check_cancelled():
return False
# Extract Wine
self.log(f"Extracting {config['wine_display_name']}...", "info")
try:
if config["archive_format"] == "gz":
with tarfile.open(wine_file, "r:gz") as tar:
tar.extractall(cache_dir, filter='data')
elif config["archive_format"] == "xz":
try:
import lzma
with lzma.open(wine_file, 'rb') as xz_file:
with tarfile.open(fileobj=xz_file, mode='r') as tar:
tar.extractall(cache_dir, filter='data')
except ImportError:
if not self.check_command("xz") and not self.check_command("unxz"):
self.log("xz or unxz is required to extract Wine archive. Please install xz.", "warning")
wine_file.unlink()
return False
tar_file = wine_file.with_suffix('.tar')
xz_cmd = "xz" if self.check_command("xz") else "unxz"
success, _, _ = self.run_command([xz_cmd, "-d", "-k", str(wine_file)], check=True)
if not success:
self.log("Failed to decompress Wine archive", "warning")
wine_file.unlink()
return False
with tarfile.open(tar_file, "r") as tar:
tar.extractall(cache_dir, filter='data')
tar_file.unlink()
wine_file.unlink()
# Find extracted directory and rename to version name
extracted_dir = next(cache_dir.glob(config["wine_dir_pattern"]), None)
if extracted_dir:
if extracted_dir != wine_dir:
if wine_dir.exists():
shutil.rmtree(wine_dir)
extracted_dir.rename(wine_dir)
self.log(f"Cached {config['wine_display_name']}", "success")
return True
else:
self.log(f"Could not find extracted Wine directory for {wine_version}", "warning")
return False
except Exception as e:
self.log(f"Failed to extract cached Wine {wine_version}: {e}", "warning")
if wine_file.exists():
wine_file.unlink()
return False
def _download_all_wine_versions_to_cache(self, selected_version):
"""Download all Wine versions to cache directory (except the one already being installed)
Args:
selected_version: The version that's already being downloaded/installed
"""
cache_dir = Path(self.directory) / "Wine-Switch"
cache_dir.mkdir(parents=True, exist_ok=True)
all_versions = ["9.14", "10.10"]
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Caching All Wine Versions", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
self.log("Downloading all Wine versions to cache for future switching...", "info")
self.log("This helps users with capped internet by avoiding re-downloads.\n", "info")
for version in all_versions:
if version == selected_version:
# Already downloading this one, skip
continue
if self.check_cancelled():
return
self._download_wine_to_cache(version, cache_dir)
self.log("\n✓ All Wine versions cached successfully!", "success")
def _cache_dxvk(self):
"""DXVK is now handled by winetricks, which manages its own caching.
This function is kept for compatibility but no longer performs manual caching.
"""
self.log("DXVK caching is handled automatically by winetricks", "info")
def _check_and_update_dxvk_vkd3d(self):
"""Check if DXVK or vkd3d-proton need updating and update them if needed
Also download DXVK if missing.
Runs in background thread to avoid blocking GUI.
"""
threading.Thread(target=self._check_and_update_dxvk_vkd3d_thread, daemon=True).start()
def _check_and_update_dxvk_vkd3d_thread(self):
"""Background thread to check DXVK/vkd3d-proton status"""
try:
# Only check if Wine is already set up
wine_dir = self.get_wine_dir()
if not wine_dir.exists():
return # Wine not set up yet, skip check
self.log("Checking DXVK and vkd3d-proton status...", "info")
# Check if DXVK is installed via winetricks
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
wine = self.get_wine_path("wine")
dxvk_installed = False
dxvk_dlls = ["d3d8", "d3d9", "d3d11", "dxgi"]
for dll in dxvk_dlls:
success, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", dll],
check=False,
env=env,
capture=True
)
if success and "native" in stdout:
dxvk_installed = True
break
if dxvk_installed:
self.log("DXVK is installed via winetricks", "info")
else:
if self.has_amd_gpu():
self.log("AMD GPU detected - DXVK should be installed via winetricks", "info")
else:
self.log("DXVK not installed (will be installed when switching to DXVK)", "info")
# Check vkd3d-proton
latest_vkd3d = self.get_latest_vkd3d_version()
if latest_vkd3d:
installed_vkd3d = self.get_installed_vkd3d_version()
cache_dir = Path(self.directory) / "dxvk"
cached_vkd3d_dir = cache_dir / f"vkd3d-proton-{latest_vkd3d}"
# Check if vkd3d-proton is missing or outdated
if not installed_vkd3d or installed_vkd3d != latest_vkd3d:
if not cached_vkd3d_dir.exists():
self.log(f"vkd3d-proton {latest_vkd3d} is missing or outdated, will download when needed", "info")
elif installed_vkd3d != latest_vkd3d:
self.log(f"vkd3d-proton update available: {installed_vkd3d} -> {latest_vkd3d}", "info")
self.log("Update will be downloaded when switching to vkd3d", "info")
self.log("DXVK and vkd3d-proton check completed", "success")
except Exception as e:
self.log(f"Error checking DXVK/vkd3d-proton updates: {e}", "warning")
def _setup_wine_switch(self, wine_version="10.10"):
"""Setup Wine binary only - for switching versions without reconfiguration
Args:
wine_version: "9.14" for Wine 9.14 (legacy), or "10.10" for Wine 10.10 (recommended)
"""
try:
# Get Wine version configuration
config = self._get_wine_version_config(wine_version)
wine_url = config["wine_url"]
wine_file_name = config["wine_file_name"]
wine_dir_name = config["wine_dir_name"]
wine_dir_pattern = config["wine_dir_pattern"]
archive_format = config["archive_format"]
wine_display_name = config["wine_display_name"]
# Check cache first
cache_dir = Path(self.directory) / "Wine-Switch"
cache_dir.mkdir(parents=True, exist_ok=True)
cached_wine_dir = cache_dir / wine_version
if cached_wine_dir.exists() and (cached_wine_dir / "bin" / "wine").exists():
# Use cached version
self.log(f"Using cached {wine_display_name}...", "info")
self.update_progress_text(f"Using cached {wine_display_name}...")
self.update_progress(0.3)
# Create directory
Path(self.directory).mkdir(parents=True, exist_ok=True)
if self.check_cancelled():
return False
# Copy from cache
self.update_progress_text("Copying Wine from cache...")
self.update_progress(0.5)
self.log("Copying Wine from cache...", "info")
# Find and link Wine directory
wine_dir = next(Path(self.directory).glob(wine_dir_pattern), None)
if wine_dir and wine_dir != Path(self.directory) / wine_dir_name:
target = Path(self.directory) / wine_dir_name
if target.exists() or target.is_symlink():
if target.is_symlink():
target.unlink()
elif target.is_dir():
shutil.rmtree(target)
target.symlink_to(wine_dir)
else:
# Copy from cache
target = Path(self.directory) / wine_dir_name
if target.exists() or target.is_symlink():
if target.is_symlink():
target.unlink()
elif target.is_dir():
shutil.rmtree(target)
shutil.copytree(cached_wine_dir, target)
self.log("Wine copied from cache", "success")
else:
# Download and cache
self.log(f"Selected version: {wine_version} -> Downloading: {wine_display_name}", "info")
self.log(f"Download URL: {wine_url}", "info")
# Create directory
Path(self.directory).mkdir(parents=True, exist_ok=True)
if self.check_cancelled():
return False
# Download Wine binary
wine_file = Path(self.directory) / wine_file_name
self.update_progress_text(f"Downloading {wine_display_name}...")
self.update_progress(0.4)
self.log(f"Downloading {wine_display_name}...", "info")
if not self.download_file(wine_url, str(wine_file), f"{wine_display_name} binaries"):
self.log(f"Failed to download {wine_display_name}", "error")
return False
if self.check_cancelled():
return False
# Extract Wine
self.update_progress_text("Extracting Wine binary...")
self.update_progress(0.6)
self.log("Extracting Wine binary...", "info")
try:
if archive_format == "gz":
with tarfile.open(wine_file, "r:gz") as tar:
tar.extractall(self.directory, filter='data')
elif archive_format == "xz":
try:
import lzma
with lzma.open(wine_file, 'rb') as xz_file:
with tarfile.open(fileobj=xz_file, mode='r') as tar:
tar.extractall(self.directory, filter='data')
except ImportError:
if not self.check_command("xz") and not self.check_command("unxz"):
self.log("xz or unxz is required to extract Wine archive. Please install xz.", "error")
return False
tar_file = wine_file.with_suffix('.tar')
xz_cmd = "xz" if self.check_command("xz") else "unxz"
success, _, _ = self.run_command([xz_cmd, "-d", "-k", str(wine_file)], check=True)
if not success:
self.log("Failed to decompress Wine archive", "error")
return False
with tarfile.open(tar_file, "r") as tar:
tar.extractall(self.directory, filter='data')
tar_file.unlink()
wine_file.unlink()
self.log("Wine binary extracted", "success")
except Exception as e:
self.log(f"Failed to extract Wine: {e}", "error")
return False
# Cache this version for future use
cache_dir.mkdir(parents=True, exist_ok=True)
extracted_dir = next(Path(self.directory).glob(wine_dir_pattern), None)
if extracted_dir:
cached_wine_dir = cache_dir / wine_version
if cached_wine_dir.exists():
shutil.rmtree(cached_wine_dir)
shutil.copytree(extracted_dir, cached_wine_dir)
self.log(f"Cached {wine_display_name} for future use", "success")
if self.check_cancelled():
return False
# Find and link Wine directory
self.update_progress(0.8)
wine_dir = next(Path(self.directory).glob(wine_dir_pattern), None)
if wine_dir and wine_dir != Path(self.directory) / wine_dir_name:
target = Path(self.directory) / wine_dir_name
if target.exists() or target.is_symlink():
if target.is_symlink():
target.unlink()
elif target.is_dir():
shutil.rmtree(target)
target.symlink_to(wine_dir)
self.log("Wine symlink created", "success")
# Verify Wine binary
self.update_progress(0.9)
wine_binary = Path(self.directory) / wine_dir_name / "bin" / "wine"
if not wine_binary.exists():
self.log("Wine binary not found", "error")
return False
self.log("Wine binary verified", "success")
self.update_progress(1.0)
return True
except Exception as e:
self.log(f"Error installing Wine: {e}", "error")
return False
def switch_wine_version(self):
"""Switch to a different Wine version - removes current and installs new one"""
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Switch Wine Version", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Check if Wine is installed
wine_dir = self.get_wine_dir()
if not wine_dir.exists():
self.log("No Wine installation found. Use 'Setup Wine Environment' to install Wine first.", "warning")
QMessageBox.warning(
self,
"No Wine Installation",
"No Wine installation found.\n\n"
"Please use 'Setup Wine Environment' to install Wine first."
)
return
# Confirm with user
reply = QMessageBox.question(
self,
"Switch Wine Version",
"This will remove the current Wine installation and install a new version.\n\n"
"Your Wine prefix and installed applications will NOT be affected.\n"
"Only the Wine binary will be replaced.\n\n"
"Do you want to continue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
self.log("Wine version switch cancelled", "warning")
return
# Ask user to choose Wine version
wine_version = self.show_question_dialog(
"Choose Wine Version",
"Which Wine version would you like to install?\n\n"
"• Wine 11.0 (Recommended) - ElementalWarrior Wine 11.0 with AMD GPU and OpenCL patches. Latest version with best compatibility and performance.\n"
"• Wine 10.10 - ElementalWarrior Wine 10.10 with AMD GPU and OpenCL patches. Previous stable version.\n"
"• Wine 9.14 (Legacy) - Legacy version with AMD GPU and OpenCL patches. Fallback option if you encounter issues with newer versions.\n\n"
"Note: This will replace your current Wine installation.",
["Wine 11.0 (Recommended)", "Wine 10.10", "Wine 9.14 (Legacy)"]
)
if wine_version == "Wine 11.0 (Recommended)":
wine_version_choice = "11.0"
elif wine_version == "Wine 10.10":
wine_version_choice = "10.10"
elif wine_version == "Wine 9.14 (Legacy)":
wine_version_choice = "9.14"
else:
self.log("Wine version switch cancelled", "warning")
return
# Run the switch in a thread
threading.Thread(target=self._switch_wine_version_thread, args=(wine_version_choice,), daemon=True).start()
def _switch_wine_version_thread(self, wine_version):
"""Thread function to switch Wine version"""
self.start_operation("Switching Wine Version")
try:
# Check if cancelled
if self.check_cancelled():
return False
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Switching Wine Version", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Step 1: Stop Wine processes
self.update_progress_text("Stopping Wine processes...")
self.update_progress(0.1)
self.log("Stopping Wine processes...", "info")
self.run_command(["wineserver", "-k"], check=False)
time.sleep(1) # Give processes time to terminate
if self.check_cancelled():
return False
# Step 2: Remove current Wine installation (not applicable for system Wine)
wine_dir = self.get_wine_dir()
if wine_dir and wine_dir.exists():
self.update_progress_text("Removing current Wine installation...")
self.update_progress(0.2)
self.log(f"Removing current Wine installation: {wine_dir}", "info")
try:
if wine_dir.is_symlink():
wine_dir.unlink()
self.log("Wine symlink removed", "success")
else:
shutil.rmtree(wine_dir)
self.log("Wine directory removed", "success")
except Exception as e:
self.log(f"Error removing Wine directory: {e}", "error")
# Try to continue anyway - setup_wine will handle it
# Also remove any old wine archive files
wine_archives = list(Path(self.directory).glob("ElementalWarrior*.tar.*"))
for archive in wine_archives:
try:
archive.unlink()
self.log(f"Removed old archive: {archive.name}", "info")
except Exception:
pass
if self.check_cancelled():
return False
# Step 3: Install new Wine version (skip configuration to preserve existing setup)
self.update_progress_text(f"Installing Wine {wine_version}...")
self.update_progress(0.3)
self.log(f"\nInstalling Wine version: {wine_version} (preserving existing configuration)...", "info")
# Call _setup_wine_switch which only replaces the binary, no reconfiguration
# Pass the wine_version parameter to ensure the correct version is downloaded
success = self._setup_wine_switch(wine_version)
if not success:
self.log(f"Failed to install Wine version: {wine_version}", "error")
if success:
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Wine version switched successfully!", "success")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
self.update_progress_text("Wine version switched")
self.update_progress(1.0)
# Refresh installation status
from PyQt6.QtCore import QTimer
QTimer.singleShot(500, self.check_installation_status)
else:
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Failed to switch Wine version", "error")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
self.update_progress_text("Failed to switch Wine version")
self.end_operation()
return success
except Exception as e:
self.log(f"Error switching Wine version: {e}", "error")
self.update_progress_text("Error switching Wine version")
self.end_operation()
return False
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()
# Check for distributions that should be directed to PikaOS instead
if self.distro in ["linuxmint", "zorin"]:
distro_name = self.format_distro_name()
message = f"""{distro_name} is not officially supported for optimal Affinity compatibility.
For better support and compatibility, we recommend installing PikaOS - a Debian-based distribution specifically optimized for gaming and compatibility:
https://wiki.pika-os.com/en/home
PikaOS provides:
• Better Wine compatibility
• Gaming-focused optimizations
• Regular updates for Affinity applications
• Debian base with enhanced package management
Would you like to continue with {distro_name} anyway?"""
reply = self.show_question_dialog(
f"{distro_name} Not Recommended",
message,
["Continue Anyway", "Cancel"]
)
if reply == "Cancel":
self.log(f"Installation cancelled by user due to {distro_name} recommendation", "warning")
self.end_operation()
return False
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 = self.get_wine_path("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
# Ensure wine-tkg is available for winetricks
self.log("Setting up wine-tkg for winetricks...", "info")
if not self.ensure_wine_tkg():
self.log("Failed to setup wine-tkg, continuing with system wine", "warning")
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
# Use wine-tkg for winetricks if available
env = self.get_winetricks_env_with_tkg(env)
components = [
("dotnet35sp1", ".NET Framework 3.5 SP1"),
("dotnet48", ".NET Framework 4.8"),
("corefonts", "Windows Core Fonts"),
("vcrun2022", "Visual C++ Redistributables 2022"),
("msxml3", "MSXML 3.0"),
("msxml6", "MSXML 6.0"),
("crypt32", "Cryptographic API 32"),
("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,
self.get_wine_path("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 = self.get_wine_path("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 = self.get_wine_path("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 = self.get_wine_path("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:
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 == "dotnet35sp1" or component == "dotnet35":
# Check for .NET 3.5 in registry (dotnet35sp1 installs .NET 3.5 SP1)
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
elif component == "crypt32":
# Check for Cryptographic API 32 (crypt32.dll)
crypt32_paths = [
Path(self.directory) / "drive_c" / "windows" / "system32" / "crypt32.dll",
Path(self.directory) / "drive_c" / "windows" / "syswow64" / "crypt32.dll",
]
for crypt32_path in crypt32_paths:
if crypt32_path.exists():
return True
except Exception:
pass
return False
def check_webview2_installed(self):
"""Check if WebView2 Runtime is already installed (fast check - file paths only)"""
# Fast check: only check file paths, skip slow registry query
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
# If file check fails, do a registry check (only if file check failed)
# Skip registry check to make it faster - file check is usually sufficient
# Registry check can be slow and may hang, so we skip it for speed
return False
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 system Wine is available (WebView2 uses system wine, not patched wine)
if not shutil.which("wine"):
self.log("System Wine is not installed. Please install Wine first.", "error")
QMessageBox.warning(
self,
"Wine Not Installed",
"System Wine is required for WebView2 Runtime installation.\n\n"
"Please install Wine using your distribution's package manager:\n"
" • Arch/CachyOS/EndeavourOS/XeroLinux: sudo pacman -S wine\n"
" • Fedora/Nobara: sudo dnf install wine\n"
" • PikaOS: sudo apt install wine"
)
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 system Wine is available (WebView2 uses system wine, not patched wine)
if not shutil.which("wine"):
self.log("System Wine is not installed. Please install Wine first.", "error")
self.log("You can install Wine using your distribution's package manager.", "info")
return False
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
# Use system wine tools for WebView2 (not patched wine)
wine_cfg = "winecfg"
regedit = "regedit"
wine = "wine"
self.log(f"Using system Wine for WebView2 installation (WINEPREFIX={self.directory})", "info")
# 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 11 compatibility mode
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 2: Download Microsoft Edge WebView2 Runtime
self.log("Downloading Microsoft Edge WebView2 Runtime...", "info")
webview2_url = "https://github.com/ryzendew/AffinityOnLinux/releases/download/10.4-Wine-Affinity/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 using system wine (like Affinity v3)
self.log("Installing Microsoft Edge WebView2 Runtime...", "info")
self.log("This may take a few minutes...", "info")
self.log("Using system Wine for WebView2 installation", "info")
env["WINEDEBUG"] = "-all"
# Use system wine for WebView2 installer (like Affinity v3)
# Use the installer capture method which has better timeout handling
success = self._run_installer_and_capture(webview2_file, env, label="WebView2 installer")
if not success:
self.log("WebView2 installer may have completed despite non-zero exit code", "warning")
# Wait a moment for files to be written
time.sleep(3)
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 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 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:
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 = self.get_wine_path("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")
elif ("affinity" in filename_lower or "affinity" in filename_no_spaces) and \
("x64" in filename_lower or "x64" in filename_no_spaces) and \
"photo" not in filename_lower and "designer" not in filename_lower and "publisher" not in filename_lower:
app_name = "Add"
self.log(f"Detected: Affinity (Unified) v3 (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
# Use regular Wine for all installations (wine-tkg is only for winetricks)
wine_cfg = self.get_wine_path("winecfg")
wine = self.get_wine_path("wine")
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env)
# Run installer
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 (only needed for Wine 9.14 and 10.10, not 11.0+)
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
self.restore_winmetadata()
else:
self.log("Skipping WinMetadata restore for Wine 11.0+ (not needed)", "info")
# Set up wintypes.dll and Wine overrides for Affinity apps (v2 and v3) - only for Wine < 11.0
if app_name in ["Photo", "Designer", "Publisher", "Add"]:
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
self.log("Setting up wintypes.dll and Wine overrides...", "info")
# Set up DLL override for wintypes.dll
self.setup_wintypes_dll_override()
# Copy wintypes.dll for the installed app
self.setup_wintypes_dll(app_name)
else:
self.log("Skipping wintypes.dll setup for Wine 11.0+ (not needed)", "info")
# 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 = self.get_wine_path("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()
# Get DXVK environment variables if AMD GPU is detected
dxvk_env = self.get_dxvk_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}'
if dxvk_env:
exec_line += f' {dxvk_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 = self.get_wine_path("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
# Use regular Wine for all installations (wine-tkg is only for winetricks)
wine_cfg = self.get_wine_path("winecfg")
self.run_command([str(wine_cfg), "-v", "win11"], check=False, env=env)
env["WINEDEBUG"] = "-all"
# Run installer with custom Wine
self.update_progress_text("Running updater...")
self.update_progress(0.4)
# Wine will be determined by _run_installer_and_capture based on installer type
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 ("Suite" in display_name or display_name == "Affinity Suite"):
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")
# Reinstall WinMetadata by downloading and extracting
self.log("Installing fresh WinMetadata...", "info")
self.setup_winmetadata()
# For Affinity v3 (Unified), reinstall settings files
if display_name and ("Unified" in display_name or display_name == "Affinity (Unified)"):
# 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")
# Check if installer is already in .AffinityLinux/Installer/ (downloaded installer)
installer_path_obj = Path(installer_path)
installer_dir = Path(self.directory) / "Installer"
# If installer is in .AffinityLinux/Installer/, use it directly
if installer_path_obj.parent == installer_dir:
self.log(f"Using installer from .AffinityLinux/Installer/: {installer_path_obj.name}", "info")
installer_file = installer_path_obj
else:
# For custom installers, copy to Wine prefix with sanitized filename (remove spaces)
self.update_progress_text("Copying installer...")
self.update_progress(0.1)
original_filename = installer_path_obj.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)
# Check if this is Affinity v3 or v2
installer_name = installer_file.name.lower()
is_affinity_v3 = (app_name == "Add" or app_name == "Affinity (Unified)") or \
("affinity" in installer_name and ("x64" in installer_name or "affinity-x64" in installer_name))
is_affinity_v2 = any(app in installer_name for app in ["photo", "designer", "publisher"]) and ".exe" in installer_name
# Use regular Wine for all installations (wine-tkg is only for winetricks)
wine_cfg = self.get_wine_path("winecfg")
wine = self.get_wine_path("wine")
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)
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 (only if it was copied to Wine prefix, not if it's in .AffinityLinux/Installer/)
self.update_progress(0.5)
if installer_file.parent != installer_dir:
# Only remove if it was copied (not the original in Installer folder)
if installer_file.exists():
installer_file.unlink()
self.log("Installer file removed", "success")
else:
self.log(f"Installer kept in .AffinityLinux/Installer/: {installer_file.name}", "info")
# Restore WinMetadata (only needed for Wine 9.14 and 10.10, not 11.0+)
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
self.update_progress_text("Restoring Windows Metadata...")
self.update_progress(0.6)
self.restore_winmetadata()
else:
self.log("Skipping WinMetadata restore for Wine 11.0+ (not needed)", "info")
# 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 v2 apps (Photo, Designer, Publisher), copy wintypes.dll and set override (only for Wine < 11.0)
if is_affinity_v2:
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
self.update_progress_text("Configuring wintypes.dll for v2 app...")
self.update_progress(0.82)
self.setup_wintypes_dll(app_name)
else:
self.log("Skipping wintypes.dll setup for Wine 11.0+ (not needed)", "info")
# For Affinity v3 (Unified), copy wintypes.dll and set override, then patch the DLL (only for Wine < 11.0)
if app_name == "Add" or app_name == "Affinity (Unified)":
wine_version = self.get_current_wine_version()
if wine_version in ["9.14", "10.10"]:
# Copy wintypes.dll and set override
self.update_progress_text("Configuring wintypes.dll for v3 app...")
self.update_progress(0.82)
self.setup_wintypes_dll(app_name)
else:
self.log("Skipping wintypes.dll setup for Wine 11.0+ (not needed)", "info")
# 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 setup_wintypes_dll(self, app_name):
"""Download and copy wintypes.dll next to exe and set up DLL override for Affinity v2 and v3 apps"""
try:
# Get app directory and exe path
app_dir = None
exe_path = None
# Handle v2 apps (Photo, Designer, Publisher)
if app_name in ["Photo", "Designer", "Publisher"]:
app_names = {
"Photo": ("Photo 2", "Photo.exe"),
"Designer": ("Designer 2", "Designer.exe"),
"Publisher": ("Publisher 2", "Publisher.exe")
}
dir_name, exe = app_names.get(app_name, (None, None))
if dir_name and exe:
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / dir_name
exe_path = app_dir / exe
# Handle v3 (Unified) app
elif app_name == "Add" or app_name == "Affinity (Unified)":
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / "Affinity"
exe_path = app_dir / "Affinity.exe"
if not app_dir or not exe_path or not exe_path.exists():
self.log(f"Exe not found for {app_name}, skipping wintypes.dll setup", "warning")
return
# Download wintypes.dll to temp location first
temp_dir = Path(self.directory) / ".temp_wintypes"
temp_dir.mkdir(exist_ok=True)
wintypes_temp = temp_dir / "wintypes.dll"
if not self._download_wintypes_dll(wintypes_temp):
self.log(f"Failed to download wintypes.dll for {app_name}", "warning")
return
# Copy wintypes.dll next to exe
wintypes_dest = app_dir / "wintypes.dll"
shutil.copy2(wintypes_temp, wintypes_dest)
self.log(f"Copied wintypes.dll to {wintypes_dest}", "success")
# Clean up temp file
try:
wintypes_temp.unlink()
except Exception:
pass
# Set up DLL override for wintypes.dll as Native (Windows)
self.setup_wintypes_dll_override()
except Exception as e:
self.log(f"Error setting up wintypes.dll: {e}", "warning")
def setup_wintypes_dll_override(self):
"""Set up DLL override for wintypes.dll as Native (Windows)"""
try:
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
# Check if override already exists
wine = self.get_wine_path("wine")
success_check, stdout, _ = self.run_command(
[str(wine), "reg", "query", "HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides", "/v", "wintypes"],
check=False,
env=env,
capture=True
)
if success_check and "native" in stdout:
self.log("wintypes.dll override already configured", "info")
else:
# Create registry file for wintypes override
reg_file = Path(self.directory) / "wintypes_override.reg"
with open(reg_file, "w") as f:
f.write("REGEDIT4\n")
f.write("[HKEY_CURRENT_USER\\Software\\Wine\\DllOverrides]\n")
f.write('"wintypes"="native"\n')
regedit = self.get_wine_path("regedit")
reg_success, _, stderr = self.run_command([str(regedit), str(reg_file)], check=False, env=env, capture=True)
reg_file.unlink()
if reg_success:
self.log("wintypes.dll override configured as Native (Windows)", "success")
else:
self.log(f"Warning: Could not configure wintypes.dll override: {stderr}", "warning")
except Exception as e:
self.log(f"Error setting up wintypes.dll override: {e}", "warning")
def copy_wintypes_dll_for_all_apps(self):
"""Download and copy wintypes.dll for all installed Affinity apps (v2 and v3)"""
try:
affinity_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity"
if not affinity_dir.exists():
self.log("Affinity installation directory not found", "warning")
return
# Download wintypes.dll to temp location first
temp_dir = Path(self.directory) / ".temp_wintypes"
temp_dir.mkdir(exist_ok=True)
wintypes_temp = temp_dir / "wintypes.dll"
if not self._download_wintypes_dll(wintypes_temp):
self.log("Failed to download wintypes.dll", "warning")
return
copied_count = 0
# Check for v2 apps (Photo 2, Designer 2, Publisher 2)
v2_apps = [
("Photo 2", "Photo.exe"),
("Designer 2", "Designer.exe"),
("Publisher 2", "Publisher.exe")
]
for dir_name, exe in v2_apps:
app_dir = affinity_dir / dir_name
exe_path = app_dir / exe
if exe_path.exists():
wintypes_dest = app_dir / "wintypes.dll"
try:
shutil.copy2(wintypes_temp, wintypes_dest)
self.log(f"Copied wintypes.dll to {wintypes_dest}", "info")
copied_count += 1
except Exception as e:
self.log(f"Warning: Could not copy wintypes.dll to {dir_name}: {e}", "warning")
# Check for v3 (Unified) app
v3_app_dir = affinity_dir / "Affinity"
v3_exe_path = v3_app_dir / "Affinity.exe"
if v3_exe_path.exists():
wintypes_dest = v3_app_dir / "wintypes.dll"
try:
shutil.copy2(wintypes_temp, wintypes_dest)
self.log(f"Copied wintypes.dll to {wintypes_dest}", "info")
copied_count += 1
except Exception as e:
self.log(f"Warning: Could not copy wintypes.dll to Affinity v3: {e}", "warning")
# Clean up temp file
try:
wintypes_temp.unlink()
except Exception:
pass
if copied_count > 0:
self.log(f"Copied wintypes.dll for {copied_count} app(s)", "success")
else:
self.log("No Affinity apps found to copy wintypes.dll", "info")
except Exception as e:
self.log(f"Error copying wintypes.dll for all apps: {e}", "warning")
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)
system32_dir = Path(self.directory) / "drive_c" / "windows" / "system32"
system32_dir.mkdir(parents=True, exist_ok=True)
try:
winmetadata_dest = system32_dir / "WinMetadata"
# Remove existing WinMetadata if it exists
if winmetadata_dest.exists():
shutil.rmtree(winmetadata_dest)
self.log("Removed existing WinMetadata folder", "info")
# Download and extract WinMetadata
if not self._download_and_extract_winmetadata(system32_dir):
self.log("Failed to restore WinMetadata", "warning")
return
# Verify WinMetadata was extracted
if not winmetadata_dest.exists():
self.log("WinMetadata restoration failed - folder not found", "warning")
return
self.log("WinMetadata restored", "success")
except Exception as e:
self.log(f"Failed to restore WinMetadata: {e}", "warning")
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 = self.get_wine_path("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 d3d12 DLLs for application (needed even when using DXVK)"""
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 = self.get_wine_dir() / "lib" / "wine" / "vkd3d-proton" / "x86_64-windows"
vkd3d_temp = Path(self.directory) / "vkd3d_dlls"
# Ensure DLLs are installed in Wine library first
if not wine_lib_dir.exists() or not (wine_lib_dir / "d3d12.dll").exists():
self.log("d3d12 DLLs not found in Wine library, installing...", "info")
self.install_d3d12_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} to {app_dir_name}", "success")
dlls_copied += 1
break
# Ensure DLL overrides are set up
self.setup_d3d12_overrides()
if dlls_copied > 0:
self.log(f"d3d12 DLLs configured for {app_dir_name}", "success")
def enable_opencl_support(self):
"""Enable OpenCL support for Affinity applications"""
# Check if Wine is set up
wine = self.get_wine_path("wine")
if not wine.exists():
QMessageBox.warning(
self,
"Wine Not Installed",
"Wine must be installed before enabling OpenCL support.\n\n"
"Please run 'One-Click Setup' or 'Setup Wine Environment' first."
)
return
# Check if OpenCL is already enabled
if self.is_opencl_enabled():
reply = QMessageBox.question(
self,
"OpenCL Already Enabled",
"OpenCL support is already enabled.\n\n"
"Would you like to reconfigure OpenCL support?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# Confirm with user
reply = QMessageBox.question(
self,
"Enable OpenCL Support",
"OpenCL (Open Computing Language) enables hardware acceleration for certain features in Affinity applications.\n\n"
"This will:\n"
"• Download and configure vkd3d-proton (or d3d12 DLLs for AMD GPUs)\n"
"• Install AMD OpenCL dependencies if AMD GPU is detected\n"
"• Configure OpenCL for all installed Affinity applications\n\n"
"Would you like to enable OpenCL support?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
)
if reply != QMessageBox.StandardButton.Yes:
return
# Run in a thread to avoid blocking UI
def enable_opencl_thread():
try:
self.update_progress(0.0)
self.update_progress_text("Enabling OpenCL support...")
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Enabling OpenCL Support", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Set OpenCL preference
self.enable_opencl = True
self.update_progress(0.1)
# Save OpenCL preference
opencl_config_file = Path(self.directory) / ".opencl_enabled"
try:
with open(opencl_config_file, 'w') as f:
f.write("1")
self.log("OpenCL preference saved", "success")
# Verify it was saved correctly
if opencl_config_file.exists():
with open(opencl_config_file, 'r') as f:
content = f.read().strip()
if content == "1":
self.log("OpenCL preference verified", "success")
else:
self.log(f"Warning: OpenCL preference file contains '{content}' instead of '1'", "warning")
else:
self.log("Error: OpenCL preference file was not created", "error")
except Exception as e:
self.log(f"Error: Failed to save OpenCL preference: {e}", "error")
import traceback
self.log(f"Traceback: {traceback.format_exc()}", "error")
self.update_progress(0.2)
# Check GPU and set up accordingly
if self.has_amd_gpu():
self.update_progress_text("AMD GPU detected - installing OpenCL dependencies...")
self.log("AMD GPU detected - installing additional OpenCL dependencies...", "info")
amd_deps = []
install_cmd = None
# Fedora
if self.distro == "fedora":
if self.distro_version == "43":
amd_deps = ["mesa-opencl-icd", "ocl-icd", "rocm-opencl", "rocm-hip", "wine-opencl"]
self.log("Fedora 43 detected - installing Fedora 43 specific AMD OpenCL dependencies...", "info")
else:
amd_deps = ["rocm-opencl", "apr", "apr-util", "zlib", "libxcrypt-compat", "libcurl", "libcurl-devel", "mesa-libGLU"]
install_cmd = ["sudo", "dnf", "install", "-y"] + amd_deps
# Arch-based distributions
elif self.distro in ["arch", "cachyos", "endeavouros", "xerolinux"]:
amd_deps = ["opencl-mesa", "ocl-icd", "rocm-opencl-runtime", "rocm-hip", "wine-opencl"]
self.log(f"{self.format_distro_name()} detected - installing Arch-based AMD OpenCL dependencies...", "info")
install_cmd = ["sudo", "pacman", "-S", "--needed", "--noconfirm"] + amd_deps
# PikaOS (Ubuntu/Debian-based)
elif self.distro == "pikaos":
amd_deps = ["mesa-opencl-icd", "ocl-icd-libopencl1", "rocm-opencl-runtime", "rocm-hip-runtime"]
self.log("PikaOS detected - installing Debian/Ubuntu-based AMD OpenCL dependencies...", "info")
install_cmd = ["sudo", "apt", "install", "-y"] + amd_deps
# Install dependencies if we have a command
if install_cmd and amd_deps:
self.log(f"Installing: {', '.join(amd_deps)}", "info")
success, stdout, stderr = self.run_command(install_cmd)
if success:
self.log("AMD OpenCL dependencies installed successfully", "success")
else:
self.log(f"Warning: Failed to install some AMD OpenCL dependencies: {stderr}", "warning")
self.log("OpenCL may still work, but some features might be limited", "warning")
self.update_progress(0.5)
self.update_progress_text("Installing d3d12 DLLs for AMD GPU...")
self.install_d3d12_dlls()
elif self.has_nvidia_gpu():
# Ask NVIDIA users to choose between DXVK and vkd3d
preference = self.ask_nvidia_dxvk_vkd3d_choice()
self.update_progress(0.4)
if preference == "dxvk":
self.update_progress_text("NVIDIA GPU with DXVK preference - installing d3d12 DLLs...")
self.log("NVIDIA GPU with DXVK preference - installing d3d12 DLLs and setting up DLL overrides", "info")
self.install_d3d12_dlls()
else:
self.update_progress_text("Setting up vkd3d-proton for OpenCL...")
self.log("Setting up vkd3d-proton for OpenCL...", "info")
self.setup_vkd3d()
else:
self.update_progress(0.4)
self.update_progress_text("Setting up vkd3d-proton for OpenCL...")
self.log("Setting up vkd3d-proton for OpenCL...", "info")
self.setup_vkd3d()
self.update_progress(0.8)
# Configure OpenCL for all installed Affinity applications
self.update_progress_text("Configuring OpenCL for Affinity applications...")
apps_to_configure = []
# Check which apps are installed
app_dirs = {
"Photo": "Photo 2",
"Designer": "Designer 2",
"Publisher": "Publisher 2",
"Add": "Affinity"
}
for app_name, app_dir_name in app_dirs.items():
app_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / app_dir_name
if app_dir.exists():
apps_to_configure.append(app_name)
if apps_to_configure:
self.log(f"Configuring OpenCL for: {', '.join(apps_to_configure)}", "info")
for app_name in apps_to_configure:
self.configure_opencl(app_name)
else:
self.log("No Affinity applications found to configure", "info")
self.update_progress(1.0)
self.update_progress_text("OpenCL support enabled!")
# Verify OpenCL is enabled
if self.is_opencl_enabled():
self.log("\n✓ OpenCL support has been enabled successfully!", "success")
self.log("OpenCL is now configured for all installed Affinity applications.", "info")
# Show success message on main thread
QTimer.singleShot(0, lambda: QMessageBox.information(
self,
"OpenCL Enabled",
"OpenCL support has been successfully enabled!\n\n"
"OpenCL is now configured for all installed Affinity applications.\n"
"You may need to restart Affinity applications for the changes to take effect."
))
else:
self.log("\n⚠ Warning: OpenCL preference may not have been saved correctly", "warning")
self.log("Please check the .opencl_enabled file in your Affinity directory", "warning")
QTimer.singleShot(0, lambda: QMessageBox.warning(
self,
"OpenCL Warning",
"OpenCL support was configured, but the preference may not have been saved correctly.\n\n"
"Please check the log for details."
))
# Refresh installation status
QTimer.singleShot(100, self.check_installation_status)
except Exception as e:
import traceback
error_msg = str(e)
error_trace = traceback.format_exc()
self.log(f"Error enabling OpenCL support: {error_msg}", "error")
self.log(f"Traceback: {error_trace}", "error")
self.update_progress_text("Error enabling OpenCL support")
# Use QTimer to show message box on main thread
QTimer.singleShot(0, lambda: QMessageBox.critical(
self,
"Error",
f"An error occurred while enabling OpenCL support:\n\n{error_msg}\n\nCheck the log for details."
))
# Start the thread
thread = threading.Thread(target=enable_opencl_thread, daemon=True)
thread.start()
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_10(self):
"""Check if .NET SDK version 10.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()
major, minor, patch = self._parse_version(version)
if major >= 10:
self.log(f".NET SDK 10.0+ found (version {version})", "success")
return True
else:
self.log(f".NET SDK found but version {version} is too old (need 10.0+)", "warning")
return False
# If dotnet command not found, check if it's installed via package manager
if self.distro in ["fedora", "nobara"]:
success, stdout, _ = self.run_command(
["dnf", "list", "installed", "dotnet-sdk*"],
check=False,
capture=True
)
if success and stdout:
for line in stdout.split('\n'):
if 'dotnet-sdk' in line.lower() and 'installed' in line.lower():
match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line)
if match:
major = int(match.group(1))
if major >= 10:
self.log(f".NET SDK 10.0+ package found via dnf: {line.split()[0]}", "success")
return True
elif self.distro in ["arch", "cachyos", "endeavouros", "xerolinux"]:
success, stdout, _ = self.run_command(
["pacman", "-Q"],
check=False,
capture=True
)
if success and stdout:
for line in stdout.split('\n'):
if 'dotnet-sdk' in line.lower():
match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line)
if match:
major = int(match.group(1))
if major >= 10:
self.log(f".NET SDK 10.0+ package found via pacman: {line.split()[0]}", "success")
return True
elif self.distro in ["pikaos", "pop", "debian"]:
success, stdout, _ = self.run_command(
["dpkg", "-l", "dotnet-sdk*"],
check=False,
capture=True
)
if success and stdout:
for line in stdout.split('\n'):
if 'dotnet-sdk' in line.lower() and line.startswith('ii'):
match = re.search(r'dotnet-sdk-(\d+)\.(\d+)', line)
if match:
major = int(match.group(1))
if major >= 10:
self.log(f".NET SDK 10.0+ package found via dpkg: {line.split()[1]}", "success")
return True
return False
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")
common_paths = [
"/usr/bin/dotnet",
"/usr/local/bin/dotnet",
"/opt/dotnet/dotnet",
Path.home() / ".dotnet" / "dotnet",
Path.home() / "bin" / "dotnet" / "dotnet",
Path.home() / "bin" / "dotnet",
]
# Also check DOTNET_ROOT environment variable if set
dotnet_root = os.environ.get("DOTNET_ROOT")
if dotnet_root:
common_paths.insert(0, Path(dotnet_root) / "dotnet")
common_paths.insert(1, Path(dotnet_root))
for path in common_paths:
path_obj = Path(path)
if path_obj.exists() and path_obj.is_file():
# 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
# 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
elif self.distro in ["opensuse-tumbleweed", "opensuse-leap"]:
# Check for any dotnet-sdk package via zypper
success, stdout, _ = self.run_command(
["zypper", "search", "-i", "dotnet-sdk*"],
check=False,
capture=True
)
if success and stdout:
# Look for any installed dotnet-sdk package
for line in stdout.split('\n'):
if 'dotnet-sdk' in line.lower() and '|' in line:
# 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:
# Extract package name (first field before |)
package_name = line.split('|')[0].strip()
self.log(f".NET SDK package found via zypper: {package_name}", "success")
# Try common paths
common_paths = [
"/usr/bin/dotnet",
"/usr/local/bin/dotnet",
"/opt/dotnet/dotnet",
Path.home() / ".dotnet" / "dotnet",
Path.home() / "bin" / "dotnet" / "dotnet",
Path.home() / "bin" / "dotnet",
]
# Also check DOTNET_ROOT if set
dotnet_root = os.environ.get("DOTNET_ROOT")
if dotnet_root:
common_paths.insert(0, Path(dotnet_root) / "dotnet")
common_paths.insert(1, Path(dotnet_root))
for path in common_paths:
path_obj = Path(path)
if path_obj.exists() and path_obj.is_file():
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 the dotnet directory to your PATH or restart your terminal", "info")
return True # Return True anyway since package is installed
return False
def ensure_patcher_files(self, silent=False):
"""Ensure AffinityPatcher and ReturnColors files are available in .AffinityLinux/Patch/"""
try:
# Destination: .AffinityLinux/Patch/AffinityPatcherSettings/
dest_patch_dir = Path(self.directory) / "Patch" / "AffinityPatcherSettings"
dest_patch_dir.mkdir(parents=True, exist_ok=True)
# Files to copy/download
files_to_get = {
"AffinityPatcher.cs": "https://raw.githubusercontent.com/ryzendew/AffinityOnLinux/main/Patch/AffinityPatcherSettings/AffinityPatcher.cs",
"AffinityPatcher.csproj": "https://raw.githubusercontent.com/ryzendew/AffinityOnLinux/main/Patch/AffinityPatcherSettings/AffinityPatcher.csproj"
}
# First, try to copy from local repository if available
script_dir = Path(__file__).parent
source_patch_dir = script_dir.parent / "Patch" / "AffinityPatcherSettings"
files_copied = False
files_downloaded = False
all_exist = True
for filename, github_url in files_to_get.items():
dest_file = dest_patch_dir / filename
# Check if file already exists and is valid
if dest_file.exists():
# File already exists, skip
continue
# Try to copy from local repository first
source_file = source_patch_dir / filename
if source_file.exists():
try:
shutil.copy2(source_file, dest_file)
files_copied = True
if not silent:
self.log(f"Copied {filename} to .AffinityLinux/Patch/AffinityPatcherSettings/", "info")
continue
except Exception as e:
if not silent:
self.log(f"Failed to copy {filename} from local: {e}", "warning")
# If local copy failed or doesn't exist, download from GitHub
if not dest_file.exists():
if not silent:
self.log(f"Downloading {filename} from GitHub...", "info")
try:
if self.download_file(github_url, str(dest_file), filename):
files_downloaded = True
if not silent:
self.log(f"Downloaded {filename} to .AffinityLinux/Patch/AffinityPatcherSettings/", "success")
else:
all_exist = False
if not silent:
self.log(f"Failed to download {filename}", "error")
except Exception as e:
all_exist = False
if not silent:
self.log(f"Error downloading {filename}: {e}", "error")
# Final check - verify all files exist
for filename in files_to_get.keys():
dest_file = dest_patch_dir / filename
if not dest_file.exists():
all_exist = False
# Download ReturnColors from GitHub (still in Patch/ directory, not in AffinityPatcherSettings/)
returncolors_dest = Path(self.directory) / "Patch" / "return-affinity-colors"
returncolors_repo = "https://github.com/ShawnTheBeachy/return-affinity-colors.git"
# Check if ReturnColors project folder exists (it's inside return-affinity-colors/ReturnColors/)
returncolors_project_exists = (returncolors_dest.exists() and
(returncolors_dest / "ReturnColors").exists() and
(returncolors_dest / "ReturnColors" / "ReturnColors.csproj").exists())
if not returncolors_project_exists:
if not silent:
self.log("Downloading ReturnColors from GitHub...", "info")
# Try git clone first (preferred method)
if self.check_command("git"):
try:
# Remove existing directory if it exists but is incomplete
if returncolors_dest.exists():
shutil.rmtree(returncolors_dest)
# Clone the repository
success, stdout, stderr = self.run_command(
["git", "clone", "--depth", "1", returncolors_repo, str(returncolors_dest)],
check=False,
capture=True
)
if success:
if not silent:
self.log("ReturnColors downloaded from GitHub via git", "success")
else:
if not silent:
self.log(f"Git clone failed: {stderr[:200] if stderr else 'Unknown error'}", "warning")
# Fall through to zip download
if returncolors_dest.exists():
shutil.rmtree(returncolors_dest)
except Exception as e:
if not silent:
self.log(f"Error cloning ReturnColors: {e}", "warning")
if returncolors_dest.exists():
try:
shutil.rmtree(returncolors_dest)
except:
pass
# Fallback: Download as zip and extract
if not returncolors_project_exists:
try:
# Download zip from GitHub
zip_url = "https://github.com/ShawnTheBeachy/return-affinity-colors/archive/refs/heads/main.zip"
temp_zip = tempfile.NamedTemporaryFile(delete=False, suffix=".zip")
temp_zip_path = Path(temp_zip.name)
temp_zip.close()
if not silent:
self.log("Downloading ReturnColors as ZIP from GitHub...", "info")
if self.download_file(zip_url, str(temp_zip_path), "ReturnColors ZIP"):
# Extract zip
with zipfile.ZipFile(temp_zip_path, 'r') as zip_ref:
# Extract to a temp directory first
temp_extract = dest_patch_dir / ".temp_returncolors"
if temp_extract.exists():
shutil.rmtree(temp_extract)
temp_extract.mkdir(exist_ok=True)
zip_ref.extractall(temp_extract)
# Move the extracted folder to the correct location
extracted_folder = temp_extract / "return-affinity-colors-main"
if extracted_folder.exists():
if returncolors_dest.exists():
shutil.rmtree(returncolors_dest)
extracted_folder.rename(returncolors_dest)
# Clean up temp directory
if temp_extract.exists():
try:
shutil.rmtree(temp_extract)
except:
pass
# Clean up zip file
temp_zip_path.unlink()
# Verify the structure is correct
if returncolors_dest.exists() and (returncolors_dest / "ReturnColors").exists() and (returncolors_dest / "ReturnColors" / "ReturnColors.csproj").exists():
if not silent:
self.log("ReturnColors downloaded and extracted from GitHub", "success")
else:
if not silent:
self.log("ReturnColors extraction failed - folder structure not found", "warning")
# Try to find the correct structure
if returncolors_dest.exists():
# Check if ReturnColors folder is at the root
if (returncolors_dest / "ReturnColors.csproj").exists():
# The zip might have extracted differently, move files
pass # Structure is already correct
else:
if not silent:
self.log("Failed to download ReturnColors ZIP from GitHub", "warning")
except Exception as e:
if not silent:
self.log(f"Error downloading ReturnColors: {e}", "warning")
elif not silent:
self.log("ReturnColors already exists in .AffinityLinux/Patch/", "info")
if files_copied and not silent:
self.log("Patcher files are ready in .AffinityLinux/Patch/", "success")
elif files_downloaded and not silent:
self.log("Patcher files downloaded and 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/AffinityPatcherSettings directory from .AffinityLinux (ensured to be available)
patch_dir = Path(self.directory) / "Patch" / "AffinityPatcherSettings"
if not patch_dir.exists():
self.log(f"AffinityPatcherSettings 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 - use absolute path and prevent building project references
# Output directory is within the AffinityPatcherSettings folder
output_dir = patch_dir / "bin" / "Release"
# Use --no-incremental for clean build and -p:BuildProjectReferences=false to prevent building other projects
# Also use absolute path to ensure we're building the correct project
# The issue is that MSBuild might be picking up files from return-affinity-colors subdirectory
# So we explicitly build only the project file and disable project references
csproj_absolute = csproj_file.resolve()
success, stdout, stderr = self.run_command(
["dotnet", "build", str(csproj_absolute), "-c", "Release", "-o", str(output_dir.resolve()),
"--no-incremental", "-p:BuildProjectReferences=false", "/p:DisableImplicitNuGetFallbackFolder=true"],
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 build_return_colors(self):
"""Build the ReturnColors .NET project"""
# Use Patch directory from .AffinityLinux
patch_dir = Path(self.directory) / "Patch"
# ReturnColors is downloaded from GitHub, so it's in return-affinity-colors/ReturnColors/
returncolors_repo_dir = patch_dir / "return-affinity-colors"
returncolors_dir = returncolors_repo_dir / "ReturnColors"
# Also check the old location for backwards compatibility
if not returncolors_dir.exists():
old_returncolors_dir = patch_dir / "ReturnColors"
if old_returncolors_dir.exists():
returncolors_dir = old_returncolors_dir
if not returncolors_dir.exists():
self.log(f"ReturnColors directory not found: {returncolors_dir}", "warning")
self.log("Attempting to download ReturnColors from GitHub...", "info")
# Try to ensure it's downloaded
self.ensure_patcher_files(silent=True)
# Check again after download attempt
returncolors_repo_dir = patch_dir / "return-affinity-colors"
returncolors_dir = returncolors_repo_dir / "ReturnColors"
if not returncolors_dir.exists():
old_returncolors_dir = patch_dir / "ReturnColors"
if old_returncolors_dir.exists():
returncolors_dir = old_returncolors_dir
if not returncolors_dir.exists():
self.log(f"ReturnColors directory still not found after download attempt", "error")
return None
csproj_file = returncolors_dir / "ReturnColors.csproj"
if not csproj_file.exists():
self.log(f"ReturnColors.csproj not found: {csproj_file}", "warning")
return None
self.log(f"Building ReturnColors from: {returncolors_dir}", "info")
# Build the project
output_dir = returncolors_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 ReturnColors: {stderr}", "warning")
if stdout:
self.log(f"Build output: {stdout}", "warning")
return None
# Find the built executable
possible_names = [
"ReturnColors", # Native executable (Linux)
"ReturnColors.dll", # DLL (runnable with dotnet)
"ReturnColors.exe", # Windows executable (unlikely on Linux)
]
returncolors_exe = None
for name in possible_names:
candidate = output_dir / name
if candidate.exists():
returncolors_exe = candidate
break
if returncolors_exe and returncolors_exe.exists():
self.log(f"ReturnColors built successfully: {returncolors_exe}", "success")
return returncolors_exe
else:
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 ReturnColors not found at expected location: {output_dir}", "warning")
return None
def run_return_colors_colorize(self, affinity_dir):
"""Run ReturnColors colorize command to restore colored icons"""
if not Path(affinity_dir).exists():
self.log(f"Affinity directory not found: {affinity_dir}", "warning")
return False
# Build ReturnColors if needed
returncolors_exe = self.build_return_colors()
if not returncolors_exe:
self.log("ReturnColors not available, skipping icon colorization", "info")
return False
self.log("Running ReturnColors to restore colored icons...", "info")
# Run ReturnColors colorize command
# The command expects: colorize
if returncolors_exe.suffix == ".dll":
cmd = ["dotnet", str(returncolors_exe), "colorize", str(affinity_dir)]
else:
cmd = [str(returncolors_exe), "colorize", str(affinity_dir)]
success, stdout, stderr = self.run_command(
cmd,
check=False,
capture=True
)
if success:
self.log("ReturnColors colorize completed successfully", "success")
if stdout:
# Log the output
for line in stdout.strip().split('\n'):
if line.strip():
if "success" in line.lower() or "completed" in line.lower():
self.log(line, "success")
elif "error" in line.lower() or "failed" in line.lower():
self.log(line, "error")
else:
self.log(line, "info")
return True
else:
self.log(f"ReturnColors colorize failed: {stderr}", "warning")
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")
# Ensure patcher files (including ReturnColors) are available
self.ensure_patcher_files(silent=True)
# 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 settings 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 = self.get_wine_path("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()
# Get DXVK environment variables if AMD GPU is detected
dxvk_env = self.get_dxvk_env_vars()
with open(desktop_file, "w") as f:
f.write("[Desktop Entry]\n")
if app_name == "Add":
f.write("Name=Affinity Suite\n")
f.write("Comment=A powerful creative suite.\n")
else:
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}'
if dxvk_env:
exec_line += f' {dxvk_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()
# Remove duplicate wine-protocol-affinity.desktop file
wine_protocol_entry = desktop_dir / "wine-protocol-affinity.desktop"
if wine_protocol_entry.exists():
try:
wine_protocol_entry.unlink()
self.log("Removed duplicate wine-protocol-affinity.desktop", "info")
except Exception as e:
self.log(f"Warning: Could not remove wine-protocol-affinity.desktop: {e}", "warning")
# Create desktop shortcut
desktop_shortcut = Path.home() / "Desktop" / desktop_file.name
if desktop_shortcut.parent.exists():
try:
shutil.copy2(desktop_file, desktop_shortcut)
self.log("Desktop shortcut created", "success")
except PermissionError:
self.log(f"Could not create desktop shortcut (permission denied): {desktop_shortcut}", "warning")
self.log("Desktop entry is still available in the applications menu", "info")
except Exception as e:
self.log(f"Could not create desktop shortcut: {e}", "warning")
self.log(f"Desktop entry created: {desktop_file}", "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 = self.get_wine_path("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 = self.get_wine_path("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 = self.get_wine_path("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 (without parent to avoid threading issues)
dialog = QDialog()
dialog.setWindowTitle("Select Renderer")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(350, int(screen_width * 0.9))
min_height = min(280, int(screen_height * 0.7))
default_width = min(450, int(screen_width * 0.85))
default_height = min(320, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 400
min_height = 300
default_width = 500
default_height = 350
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 400
min_height = 300
default_width = 500
default_height = 350
max_width = 750
max_height = 600
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
# Apply theme stylesheet matching main UI
if self.dark_mode:
dialog_style = """
QDialog {
background-color: #252526;
color: #dcdcdc;
}
QLabel {
color: #dcdcdc;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4ec9b0;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #cccccc;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QFrame#optionFrame {
background-color: #2d2d2d;
border: 1px solid #3c3c3c;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #4a4a4a;
background-color: #323232;
}
QRadioButton {
font-size: 16px;
color: #dcdcdc;
padding: 8px 0px;
spacing: 10px;
font-weight: 500;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #555555;
background-color: #3c3c3c;
}
QRadioButton::indicator:hover {
border-color: #6a6a6a;
}
QRadioButton::indicator:checked {
background-color: #4ec9b0;
border-color: #4ec9b0;
}
QPushButton {
background-color: #3c3c3c;
color: #f0f0f0;
border: 1px solid #555555;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #4a4a4a;
border-color: #6a6a6a;
}
QPushButton:pressed {
background-color: #2d2d2d;
}
QPushButton#okButton {
background-color: #4ec9b0;
color: #1e1e1e;
border: 1px solid #4ec9b0;
font-weight: bold;
}
QPushButton#okButton:hover {
background-color: #5dd9c0;
border-color: #5dd9c0;
}
QPushButton#okButton:pressed {
background-color: #3db9a0;
}
QScrollArea {
border: none;
background-color: transparent;
}
QScrollBar:vertical {
background-color: #2d2d2d;
width: 12px;
border-radius: 6px;
}
QScrollBar::handle:vertical {
background-color: #555555;
border-radius: 6px;
min-height: 30px;
}
QScrollBar::handle:vertical:hover {
background-color: #666666;
}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
height: 0px;
}
"""
else:
dialog_style = """
QDialog {
background-color: #ffffff;
color: #2d2d2d;
}
QLabel {
color: #2d2d2d;
background-color: transparent;
}
QLabel#titleLabel {
font-size: 18px;
font-weight: bold;
color: #4caf50;
padding: 10px 0px;
}
QLabel#descriptionLabel {
font-size: 13px;
color: #555555;
padding: 5px 0px 15px 0px;
line-height: 1.4;
}
QLabel#optionDescription {
font-size: 12px;
color: #666666;
padding: 4px 0px 0px 0px;
line-height: 1.4;
}
QFrame#optionFrame {
background-color: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 8px;
margin: 4px 0px;
}
QFrame#optionFrame:hover {
border-color: #c0c0c0;
background-color: #fafafa;
}
QRadioButton {
font-size: 14px;
color: #2d2d2d;
padding: 8px 0px;
spacing: 10px;
}
QRadioButton::indicator {
width: 18px;
height: 18px;
border-radius: 9px;
border: 2px solid #c0c0c0;
background-color: #ffffff;
}
QRadioButton::indicator:hover {
border-color: #a0a0a0;
}
QRadioButton::indicator:checked {
background-color: #4caf50;
border-color: #4caf50;
}
QPushButton {
background-color: #e0e0e0;
color: #2d2d2d;
border: 1px solid #c0c0c0;
border-radius: 8px;
min-width: 100px;
padding: 10px 20px;
font-size: 13px;
font-weight: 500;
}
QPushButton:hover {
background-color: #d0d0d0;
border-color: #a0a0a0;
}
QPushButton:pressed {
background-color: #c0c0c0;
}
QPushButton#okButton {
background-color: #4caf50;
color: #ffffff;
border: 1px solid #4caf50;
font-weight: bold;
}
QPushButton#okButton:hover {
background-color: #45a049;
border-color: #45a049;
}
QPushButton#okButton:pressed {
background-color: #3d8b40;
}
QScrollArea {
border: none;
background-color: transparent;
}
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;
}
"""
dialog.setStyleSheet(dialog_style)
# Main layout with responsive margins
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("Select Renderer")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# Description
desc_label = QLabel("Choose a renderer for troubleshooting:")
desc_label.setObjectName("descriptionLabel")
desc_label.setWordWrap(True)
desc_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(desc_label)
# Options container with scroll area for better scaling
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setFrameShape(QFrame.Shape.NoFrame)
scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
options_container = QFrame()
options_container.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
options_layout = QVBoxLayout(options_container)
options_layout.setSpacing(8)
options_margin = 8 if (screen_width >= 800 and screen_height >= 600) else 6
options_layout.setContentsMargins(options_margin, options_margin, options_margin, options_margin)
scroll_area.setWidget(options_container)
button_group = QButtonGroup()
# Vulkan option
vulkan_frame = QFrame()
vulkan_frame.setObjectName("optionFrame")
vulkan_layout = QVBoxLayout(vulkan_frame)
vulkan_layout.setContentsMargins(12, 10, 12, 10)
vulkan_radio = QRadioButton("Vulkan (Recommended - OpenCL support)")
vulkan_radio.setChecked(True)
vulkan_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
vulkan_layout.addWidget(vulkan_radio)
options_layout.addWidget(vulkan_frame)
# OpenGL option
opengl_frame = QFrame()
opengl_frame.setObjectName("optionFrame")
opengl_layout = QVBoxLayout(opengl_frame)
opengl_layout.setContentsMargins(12, 10, 12, 10)
opengl_radio = QRadioButton("OpenGL (Alternative)")
opengl_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
opengl_layout.addWidget(opengl_radio)
options_layout.addWidget(opengl_frame)
# GDI option
gdi_frame = QFrame()
gdi_frame.setObjectName("optionFrame")
gdi_layout = QVBoxLayout(gdi_frame)
gdi_layout.setContentsMargins(12, 10, 12, 10)
gdi_radio = QRadioButton("GDI (Fallback)")
gdi_radio.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
gdi_layout.addWidget(gdi_radio)
options_layout.addWidget(gdi_frame)
button_group.addButton(vulkan_radio, 0)
button_group.addButton(opengl_radio, 1)
button_group.addButton(gdi_radio, 2)
main_layout.addWidget(scroll_area, 1)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
ok_btn = QPushButton("Continue")
ok_btn.setObjectName("okButton")
ok_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
ok_btn.setDefault(True)
ok_btn.clicked.connect(dialog.accept)
button_layout.addWidget(ok_btn)
main_layout.addLayout(button_layout)
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"))
# Ensure wine-tkg is available for winetricks (fallback method)
self.log("Setting up wine-tkg for winetricks (if needed)...", "info")
self.ensure_wine_tkg() # Don't fail if this doesn't work, it's just a fallback
env = os.environ.copy()
env["WINEPREFIX"] = self.directory
# Use wine-tkg for winetricks if available (fallback method)
env = self.get_winetricks_env_with_tkg(env)
# 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 = self.get_wine_path("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, version="8.0"):
"""Install .NET SDK based on distribution"""
try:
self.log(f"Installing .NET SDK {version}...", "info")
# Determine package name based on version
if version == "10.0":
package_name = "dotnet-sdk-10.0"
else:
package_name = "dotnet-sdk-8.0"
if self.distro in ["pikaos", "pop", "debian", "ubuntu", "linuxmint", "zorin"]:
# Try installing dotnet-sdk (may need Microsoft repo)
success, _, stderr = self.run_command([
"sudo", "apt", "install", "-y", package_name
], check=False)
if not success:
self.log(f"Failed to install {package_name} 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", package_name],
"cachyos": ["sudo", "pacman", "-S", "--needed", "--noconfirm", package_name],
"endeavouros": ["sudo", "pacman", "-S", "--needed", "--noconfirm", package_name],
"xerolinux": ["sudo", "pacman", "-S", "--needed", "--noconfirm", package_name],
"fedora": ["sudo", "dnf", "install", "-y", package_name],
"nobara": ["sudo", "dnf", "install", "-y", package_name],
"opensuse-tumbleweed": ["sudo", "zypper", "install", "-y", package_name],
"opensuse-leap": ["sudo", "zypper", "install", "-y", package_name]
}
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 apply_return_colors(self):
"""Apply ReturnColors patch to restore colored icons in Affinity v3"""
self.log("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
self.log("Return Colors (Affinity v3)", "info")
self.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
# Check if Wine is set up
wine_binary = self.get_wine_path("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 applying patches.\n"
"Please setup Wine environment first."
)
return
# Check if Affinity v3 is installed
affinity_dir = Path(self.directory) / "drive_c" / "Program Files" / "Affinity" / "Affinity"
dll_path = affinity_dir / "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 patch only works for Affinity v3 (Unified).\n"
"Please install Affinity v3 first using the 'Affinity (Unified)' button.",
"error"
)
return
self.start_operation("Return Colors")
# Ensure patcher files are available
self.ensure_patcher_files()
# Check if .NET SDK 10.0+ is installed (required for ReturnColors)
if not self.check_dotnet_sdk_10():
self.log(".NET SDK 10.0+ is required for ReturnColors patch", "warning")
self.log("Attempting to install .NET SDK 10.0 automatically...", "info")
# Try to install dotnet-sdk-10.0 automatically
install_success = False
if self.distro in ["pikaos", "pop", "debian", "ubuntu", "linuxmint", "zorin"]:
success, _, _ = self.run_command([
"sudo", "apt", "install", "-y", "dotnet-sdk-10.0"
], check=False)
if success:
install_success = True
else:
self.log("Failed to install dotnet-sdk-10.0 from default repos", "warning")
elif self.distro in ["arch", "cachyos"]:
success, _, _ = self.run_command([
"sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-10.0"
], check=False)
if success:
install_success = True
elif self.distro in ["endeavouros", "xerolinux"]:
success, _, _ = self.run_command([
"sudo", "pacman", "-S", "--needed", "--noconfirm", "dotnet-sdk-10.0"
], check=False)
if success:
install_success = True
elif self.distro in ["fedora", "nobara"]:
success, _, _ = self.run_command([
"sudo", "dnf", "install", "-y", "dotnet-sdk-10.0"
], check=False)
if success:
install_success = True
elif self.distro in ["opensuse-tumbleweed", "opensuse-leap"]:
success, _, _ = self.run_command([
"sudo", "zypper", "install", "-y", "dotnet-sdk-10.0"
], check=False)
if success:
install_success = True
# Check again if installation succeeded
if install_success and self.check_dotnet_sdk_10():
self.log(".NET SDK 10.0 installed successfully", "success")
else:
# Installation failed or still not detected, show manual instructions
self.log(".NET SDK 10.0+ is required for ReturnColors patch", "error")
self.log("ReturnColors requires .NET SDK 10.0 or newer to build.", "info")
self.log("Please install .NET SDK 10.0 manually:", "info")
install_instructions = ""
if self.distro in ["arch", "cachyos"]:
self.log(" sudo pacman -S dotnet-sdk-10.0", "info")
install_instructions = "sudo pacman -S dotnet-sdk-10.0"
elif self.distro in ["endeavouros", "xerolinux"]:
self.log(" sudo pacman -S dotnet-sdk-10.0", "info")
install_instructions = "sudo pacman -S dotnet-sdk-10.0"
elif self.distro in ["fedora", "nobara"]:
self.log(" sudo dnf install dotnet-sdk-10.0", "info")
install_instructions = "sudo dnf install dotnet-sdk-10.0"
elif self.distro in ["pikaos", "pop", "debian", "ubuntu", "linuxmint", "zorin"]:
self.log(" sudo apt install dotnet-sdk-10.0", "info")
self.log(" (May require Microsoft's .NET repository)", "warning")
install_instructions = "sudo apt install dotnet-sdk-10.0\n(May require Microsoft's .NET repository)"
elif self.distro in ["opensuse-tumbleweed", "opensuse-leap"]:
self.log(" sudo zypper install dotnet-sdk-10.0", "info")
install_instructions = "sudo zypper install dotnet-sdk-10.0"
else:
self.log(" Please install .NET SDK 10.0 from: https://dotnet.microsoft.com/download", "info")
install_instructions = "Install from: https://dotnet.microsoft.com/download"
# Ensure distro is detected before trying alternative method
if not self.distro:
self.detect_distro()
# Try to install using the install_dotnet_sdk method as fallback
self.log("Attempting to install .NET SDK using alternative method...", "info")
if self.install_dotnet_sdk(version="10.0"):
# Check again if installation succeeded
if self.check_dotnet_sdk_10():
self.log(".NET SDK 10.0 installed successfully via alternative method", "success")
else:
self.end_operation()
self.show_message(
".NET SDK 10.0 Required",
"ReturnColors patch requires .NET SDK 10.0 or newer to build.\n\n"
f"Please install it manually:\n{install_instructions}\n\n"
"After installing, restart the installer and try again.",
"error"
)
return
else:
self.end_operation()
self.show_message(
".NET SDK 10.0 Required",
"ReturnColors patch requires .NET SDK 10.0 or newer to build.\n\n"
f"Please install it manually:\n{install_instructions}\n\n"
"After installing, restart the installer and try again.",
"error"
)
return
# Run ReturnColors colorize
success = self.run_return_colors_colorize(str(affinity_dir))
if success:
self.log("\n✓ ReturnColors patch applied successfully!", "success")
self.log("Affinity v3 icons have been restored to colored versions.", "info")
self.end_operation()
self.show_message(
"Patch Applied Successfully",
"ReturnColors patch has been applied successfully!\n\n"
"Affinity v3 icons have been restored to colored versions.\n"
"You may need to restart Affinity v3 to see the changes.",
"info"
)
else:
self.log("\n✗ ReturnColors patch failed", "error")
self.end_operation()
self.show_message(
"Patch Failed",
"Failed to apply ReturnColors patch.\n\n"
"Please check the log for details and ensure:\n"
"• Affinity v3 is installed\n"
"• .NET SDK is installed\n"
"• ReturnColors files are available",
"error"
)
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 = self.get_wine_path("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 (without parent to avoid threading issues)
dialog = QDialog()
dialog.setWindowTitle("Set DPI Scaling")
dialog.setModal(True)
dialog.setWindowFlags(dialog.windowFlags() | Qt.WindowType.WindowStaysOnTopHint)
# Responsive sizing
screen = dialog.screen().availableGeometry()
screen_width = screen.width()
screen_height = screen.height()
if screen_width < 800 or screen_height < 600:
min_width = min(400, int(screen_width * 0.9))
min_height = min(350, int(screen_height * 0.7))
default_width = min(500, int(screen_width * 0.85))
default_height = min(400, int(screen_height * 0.65))
max_width = int(screen_width * 0.95)
max_height = int(screen_height * 0.85)
elif screen_width < 1280 or screen_height < 720:
min_width = 450
min_height = 380
default_width = 550
default_height = 420
max_width = int(screen_width * 0.9)
max_height = int(screen_height * 0.85)
else:
min_width = 450
min_height = 380
default_width = 550
default_height = 420
max_width = 800
max_height = 700
dialog.setMinimumWidth(min_width)
dialog.setMinimumHeight(min_height)
dialog.setMaximumWidth(max_width)
dialog.setMaximumHeight(max_height)
dialog.resize(default_width, default_height)
dialog.setSizeGripEnabled(True)
dialog.setStyleSheet(self.get_dialog_stylesheet())
# Main layout
main_layout = QVBoxLayout(dialog)
main_layout.setSpacing(12)
margin = 20 if (screen_width >= 800 and screen_height >= 600) else 15
main_layout.setContentsMargins(margin, margin, margin, margin)
# Title
title_label = QLabel("Set DPI Scaling")
title_label.setObjectName("titleLabel")
title_label.setWordWrap(True)
title_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(title_label)
# 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.setObjectName("descriptionLabel")
info_label.setWordWrap(True)
info_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(info_label)
# Current value display
value_label = QLabel()
value_label.setObjectName("titleLabel")
value_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
value_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_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
slider.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
main_layout.addWidget(slider)
# Min/Max labels
minmax_layout = QHBoxLayout()
min_label = QLabel("96 (100%)")
min_label.setObjectName("descriptionLabel")
minmax_layout.addWidget(min_label)
minmax_layout.addStretch()
max_label = QLabel("480 (500%)")
max_label.setObjectName("descriptionLabel")
minmax_layout.addWidget(max_label)
main_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
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
button_layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
cancel_btn.clicked.connect(dialog.reject)
button_layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setObjectName("okButton")
save_btn.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Minimum)
save_btn.setDefault(True)
save_btn.clicked.connect(dialog.accept)
button_layout.addWidget(save_btn)
main_layout.addLayout(button_layout)
# Show dialog
dialog.show()
dialog.raise_()
dialog.activateWindow()
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"
"• Desktop entries from .local/share/applications\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")
# Remove desktop entries from .local/share/applications
self.log("Removing desktop entries...", "info")
desktop_dir = Path.home() / ".local" / "share" / "applications"
desktop_files = [
desktop_dir / "AffinityPhoto.desktop",
desktop_dir / "AffinityDesigner.desktop",
desktop_dir / "AffinityPublisher.desktop",
desktop_dir / "Affinity.desktop"
]
removed_count = 0
for desktop_file in desktop_files:
if desktop_file.exists():
try:
desktop_file.unlink()
self.log(f"Removed desktop entry: {desktop_file.name}", "info")
removed_count += 1
except Exception as e:
self.log(f"Warning: Could not remove {desktop_file.name}: {e}", "warning")
# Also remove Wine's default entries if they exist
wine_desktop_dir = desktop_dir / "wine" / "Programs"
wine_entries = [
wine_desktop_dir / "Affinity Photo 2.desktop",
wine_desktop_dir / "Affinity Photo.desktop",
wine_desktop_dir / "Affinity Designer 2.desktop",
wine_desktop_dir / "Affinity Designer.desktop",
wine_desktop_dir / "Affinity Publisher 2.desktop",
wine_desktop_dir / "Affinity Publisher.desktop",
wine_desktop_dir / "Affinity.desktop"
]
for wine_entry in wine_entries:
if wine_entry.exists():
try:
wine_entry.unlink()
self.log(f"Removed Wine desktop entry: {wine_entry.name}", "info")
removed_count += 1
except Exception as e:
self.log(f"Warning: Could not remove {wine_entry.name}: {e}", "warning")
if removed_count > 0:
self.log(f"Removed {removed_count} desktop entry/entries", "success")
# 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
wine_bin = self.get_wine_path("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 (only for custom Wine builds)
wine_dir = self.get_wine_dir()
if wine_dir:
wine_dir_str = str(wine_dir)
current_path = env.get("PATH", "")
env["PATH"] = f"{wine_dir_str}/bin:{current_path}"
# For system Wine, it's already in 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 = self.get_wine_path("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() # No parent to avoid threading issues
thanks.setWindowTitle("Special Thanks")
thanks.setStyleSheet(self.get_messagebox_stylesheet())
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"""
import time as time_module
total_start_time = time_module.time()
if platform.system() != "Linux":
app = QApplication(sys.argv)
QMessageBox.critical(
None,
"Unsupported Platform",
"This installer is designed for Linux systems only."
)
return
app_init_start = time_module.time()
app = QApplication(sys.argv)
app_init_time = time_module.time() - app_init_start
window_init_start = time_module.time()
window = AffinityInstallerGUI()
window_init_time = time_module.time() - window_init_start
# Show window immediately - slow operations will run in background
window.show()
# Process events to ensure window is displayed before background tasks start
app.processEvents()
total_init_time = time_module.time() - total_start_time
print(f"\n[Startup Timing] QApplication init: {app_init_time:.3f}s")
print(f"[Startup Timing] Window init: {window_init_time:.3f}s")
print(f"[Startup Timing] Total startup: {total_init_time:.3f}s")
print(f"[Startup Timing] Window shown immediately - background tasks running...\n")
sys.exit(app.exec())
if __name__ == "__main__":
main()