diff --git a/.gitignore b/.gitignore index b5241ef..0e53dfa 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ /venv/ .env venv/ +/.eggs/ +/.ai/ +/.github/ diff --git a/requirements.txt b/requirements.txt index 512050a..7cb7336 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,41 +1,20 @@ -PyQt6>=6.6.1 +PyQt6>=6.7.0 psutil>=5.9.8 -requests>=2.31.0 -Pillow>=10.2.0 +requests>=2.32.2 +Pillow>=10.3.0 PyYAML>=6.0.1 -flask>=3.0.2 -tqdm>=4.66.2 -werkzeug>=3.0.1 +flask>=3.0.3 +tqdm>=4.66.4 +werkzeug>=3.0.3 Pypubsub>=4.0.3 -zeroconf>=0.131.0 -SQLAlchemy>=2.0.25 +zeroconf>=0.132.2 +SQLAlchemy>=2.0.30 plyer>=2.1.0 -pytz>=2023.3.post1 -future>=0.18.3 -rich>=13.7.0 -pytest>=8.0.0 -numpy>=1.26.3 -setuptools>=69.0.3 -pandas>=2.2.0 -matplotlib>=3.8.2 -MarkupSafe>=2.1.4 -dmglib>=0.9.5; sys_platform == 'darwin' -python-dateutil>=2.8.2 -certifi>=2023.11.17 -shiboken6>=6.6.1 -Pygments>=2.17.2 -cycler>=0.12.1 -contourpy>=1.2.0 -packaging>=23.2 -fonttools>=4.47.2 -Jinja2>=3.1.3 -pyparsing>=3.1.1 -kiwisolver>=1.4.5 -attrs>=23.2.0 -lxml>=5.1.0 -click>=8.1.7 -requests_toolbelt>=1.0.0 -pyinstaller_versionfile>=2.1.1 -py-cpuinfo~=9.0.0 -requests-toolbelt~=1.0.0 -ifaddr~=0.2.0 \ No newline at end of file +rich>=13.7.1 +setuptools>=70.0.0 +py-cpuinfo>=9.0.0 +requests-toolbelt>=1.0.0 +PyQt6-sip>=13.6.0 +humanize>=4.12.1 +macholib>=1.16.3 +altgraph>=0.17.4 \ No newline at end of file diff --git a/server.py b/server.py index ee55c75..b21148e 100755 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ from src.engines.engine_manager import EngineManager from src.render_queue import RenderQueue from src.utilities.config import Config from src.utilities.misc_helper import (get_gpu_info, system_safe_path, current_system_cpu, current_system_os, - current_system_os_version, check_for_updates) + current_system_os_version, current_system_cpu_brand, check_for_updates) from src.utilities.zeroconf_server import ZeroconfServer from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER @@ -93,10 +93,6 @@ def start_server(skip_updates=False) -> int: ServerProxyManager.subscribe_to_listener() DistributedJobManager.subscribe_to_listener() - # check for updates for render engines if configured or on first launch - if Config.update_engines_on_launch or not EngineManager.get_engines(): - EngineManager.update_all_engines() - # get hostname local_hostname = socket.gethostname() @@ -108,7 +104,7 @@ def start_server(skip_updates=False) -> int: # start zeroconf server ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number) ZeroconfServer.properties = {'system_cpu': current_system_cpu(), - 'system_cpu_brand': cpuinfo.get_cpu_info()['brand_raw'], + 'system_cpu_brand': current_system_cpu_brand(), 'system_cpu_cores': multiprocessing.cpu_count(), 'system_os': current_system_os(), 'system_os_version': current_system_os_version(), @@ -118,6 +114,11 @@ def start_server(skip_updates=False) -> int: ZeroconfServer.start() logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}") RenderQueue.start() # Start evaluating the render queue + + # check for updates for render engines if configured or on first launch + # if Config.update_engines_on_launch or not EngineManager.get_engines(): + # EngineManager.update_all_engines() + api_server.join() except KeyboardInterrupt: diff --git a/src/engines/blender/blender_downloader.py b/src/engines/blender/blender_downloader.py index 7f2b391..c3b141b 100644 --- a/src/engines/blender/blender_downloader.py +++ b/src/engines/blender/blender_downloader.py @@ -8,8 +8,7 @@ from src.engines.blender.blender_engine import Blender from src.engines.core.base_downloader import EngineDownloader from src.utilities.misc_helper import current_system_os, current_system_cpu -# url = "https://download.blender.org/release/" -url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing +url = "https://download.blender.org/release/" logger = logging.getLogger() supported_formats = ['.zip', '.tar.xz', '.dmg'] @@ -88,8 +87,8 @@ class BlenderDownloader(EngineDownloader): threads = [] results = [[] for _ in majors] - def thread_function(major_version, index, system_os, cpu): - results[index] = cls.__get_minor_versions(major_version, system_os, cpu) + def thread_function(major_version, index, system_os_t, cpu_t): + results[index] = cls.__get_minor_versions(major_version, system_os_t, cpu_t) for i, m in enumerate(majors): thread = threading.Thread(target=thread_function, args=(m, i, system_os, cpu)) @@ -126,7 +125,7 @@ class BlenderDownloader(EngineDownloader): return None @classmethod - def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120): + def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None): system_os = system_os or current_system_os() cpu = cpu or current_system_cpu() @@ -136,7 +135,7 @@ class BlenderDownloader(EngineDownloader): minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if x['version'] == version] cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location, - timeout=timeout) + timeout=timeout, progress_callback=progress_callback) except IndexError: logger.error("Cannot find requested engine") diff --git a/src/engines/core/base_downloader.py b/src/engines/core/base_downloader.py index f1d370f..1767345 100644 --- a/src/engines/core/base_downloader.py +++ b/src/engines/core/base_downloader.py @@ -74,7 +74,7 @@ class EngineDownloader: raise NotImplementedError(f"version_is_available_to_download not implemented for {cls.__class__.__name__}") @classmethod - def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120): + def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None): """Downloads the requested version of the rendering engine to the given download location. This method should be overridden in a subclass to implement the logic for downloading @@ -88,6 +88,7 @@ class EngineDownloader: system_os (str, optional): Desired OS ('linux', 'macos', 'windows'). Defaults to system os. cpu (str, optional): The CPU architecture for which to download the engine. Default is system cpu. timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds. + progress_callback (callable, optional): A callback function that is called periodically with the current download progress. Raises: NotImplementedError: If the method is not overridden in a subclass. @@ -125,7 +126,7 @@ class EngineDownloader: # -------------------------------------------- @classmethod - def download_and_extract_app(cls, remote_url, download_location, timeout=120): + def download_and_extract_app(cls, remote_url, download_location, timeout=120, progress_callback=None): """Downloads an application from the given remote URL and extracts it to the specified location. This method handles the downloading of the application, supports multiple archive formats, @@ -136,6 +137,7 @@ class EngineDownloader: remote_url (str): The URL of the application to download. download_location (str): The directory where the application should be extracted. timeout (int, optional): The maximum time in seconds to wait for the download. Default is 120 seconds. + progress_callback (callable, optional): A callback function that is called periodically with the current download progress. Returns: str: The path to the directory where the application was extracted. @@ -154,6 +156,8 @@ class EngineDownloader: and return without downloading or extracting. - Temporary files created during the download process are cleaned up after completion. """ + if progress_callback: + progress_callback(0) # Create a temp download directory temp_download_dir = tempfile.mkdtemp() @@ -166,7 +170,7 @@ class EngineDownloader: if os.path.exists(os.path.join(download_location, output_dir_name)): logger.error(f"Engine download for {output_dir_name} already exists") - return + return None if not os.path.exists(temp_downloaded_file_path): # Make a GET request to the URL with stream=True to enable streaming @@ -182,20 +186,26 @@ class EngineDownloader: progress_bar = tqdm(total=file_size, unit="B", unit_scale=True) # Open a file for writing in binary mode + total_saved = 0 with open(temp_downloaded_file_path, "wb") as file: for chunk in response.iter_content(chunk_size=1024): if chunk: # Write the chunk to the file file.write(chunk) + total_saved += len(chunk) # Update the progress bar progress_bar.update(len(chunk)) + if progress_callback: + percent = float(total_saved) / float(file_size) + progress_callback(percent) # Close the progress bar + progress_callback(1.0) progress_bar.close() logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}") else: logger.error(f"Failed to download the file. Status code: {response.status_code}") - return + return None os.makedirs(download_location, exist_ok=True) diff --git a/src/engines/engine_manager.py b/src/engines/engine_manager.py index d59f1e9..4dbf345 100644 --- a/src/engines/engine_manager.py +++ b/src/engines/engine_manager.py @@ -23,6 +23,14 @@ class EngineManager: def supported_engines(): return [Blender, FFMPEG] + @classmethod + def downloadable_engines(cls): + return [engine for engine in cls.supported_engines() if hasattr(engine, "downloader") and engine.downloader()] + + @classmethod + def active_downloads(cls) -> list: + return [x for x in cls.download_tasks if x.is_alive()] + @classmethod def engine_with_name(cls, engine_name): for obj in cls.supported_engines(): @@ -30,7 +38,15 @@ class EngineManager: return obj @classmethod - def get_engines(cls, filter_name=None, include_corrupt=False): + def update_all_engines(cls): + for engine in cls.downloadable_engines(): + update_available = cls.is_engine_update_available(engine) + if update_available: + update_available['name'] = engine.name() + cls.download_engine(engine.name(), update_available['version'], background=True) + + @classmethod + def get_engines(cls, filter_name=None, include_corrupt=False, ignore_system=False): if not cls.engines_path: raise FileNotFoundError("Engine path is not set") @@ -63,13 +79,13 @@ class EngineManager: ) result_dict['path'] = path - # fetch version number from binary - helps detect corrupted downloads - binary_version = eng(path).version() - if not binary_version: - logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}") - if not include_corrupt: - continue - result_dict['version'] = binary_version or 'error' + # fetch version number from binary - helps detect corrupted downloads - disabled due to perf issues + # binary_version = eng(path).version() + # if not binary_version: + # logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}") + # if not include_corrupt: + # continue + # result_dict['version'] = binary_version or 'error' # Add the result dictionary to results if it matches the filter_name or if no filter is applied if not filter_name or filter_name == result_dict['engine']: @@ -92,46 +108,47 @@ class EngineManager: 'type': 'system' } - with concurrent.futures.ThreadPoolExecutor() as executor: - futures = { - executor.submit(fetch_engine_details, eng, include_corrupt): eng.name() - for eng in cls.supported_engines() - if eng.default_engine_path() and (not filter_name or filter_name == eng.name()) - } + if not ignore_system: + with concurrent.futures.ThreadPoolExecutor() as executor: + futures = { + executor.submit(fetch_engine_details, eng, include_corrupt): eng.name() + for eng in cls.supported_engines() + if eng.default_engine_path() and (not filter_name or filter_name == eng.name()) + } - for future in concurrent.futures.as_completed(futures): - result = future.result() - if result: - results.append(result) + for future in concurrent.futures.as_completed(futures): + result = future.result() + if result: + results.append(result) return results @classmethod - def all_versions_for_engine(cls, engine_name, include_corrupt=False): - versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt) + def all_versions_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False): + versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system) sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True) return sorted_versions @classmethod - def newest_engine_version(cls, engine, system_os=None, cpu=None): + def newest_engine_version(cls, engine, system_os=None, cpu=None, ignore_system=None): system_os = system_os or current_system_os() cpu = cpu or current_system_cpu() try: - filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and - x['cpu'] == cpu] + filtered = [x for x in cls.all_versions_for_engine(engine, ignore_system=ignore_system) + if x['system_os'] == system_os and x['cpu'] == cpu] return filtered[0] except IndexError: logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}") return None @classmethod - def is_version_downloaded(cls, engine, version, system_os=None, cpu=None): + def is_version_downloaded(cls, engine, version, system_os=None, cpu=None, ignore_system=False): system_os = system_os or current_system_os() cpu = cpu or current_system_cpu() - filtered = [x for x in cls.get_engines(filter_name=engine) if x['system_os'] == system_os and - x['cpu'] == cpu and x['version'] == version] + filtered = [x for x in cls.get_engines(filter_name=engine, ignore_system=ignore_system) if + x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version] return filtered[0] if filtered else False @classmethod @@ -164,7 +181,7 @@ class EngineManager: return None @classmethod - def download_engine(cls, engine, version, system_os=None, cpu=None, background=False): + def download_engine(cls, engine, version, system_os=None, cpu=None, background=False, ignore_system=False): engine_to_download = cls.engine_with_name(engine) existing_task = cls.get_existing_download_task(engine, version, system_os, cpu) @@ -172,10 +189,10 @@ class EngineManager: logger.debug(f"Already downloading {engine} {version}") if not background: existing_task.join() # If download task exists, wait until it's done downloading - return + return None elif not engine_to_download.downloader(): logger.warning("No valid downloader for this engine. Please update this software manually.") - return + return None elif not cls.engines_path: raise FileNotFoundError("Engines path must be set before requesting downloads") @@ -187,7 +204,7 @@ class EngineManager: return thread thread.join() - found_engine = cls.is_version_downloaded(engine, version, system_os, cpu) # Check that engine downloaded + found_engine = cls.is_version_downloaded(engine, version, system_os, cpu, ignore_system) # Check that engine downloaded if not found_engine: logger.error(f"Error downloading {engine}") return found_engine @@ -213,31 +230,21 @@ class EngineManager: return False @classmethod - def update_all_engines(cls): - def engine_update_task(engine_class): - logger.debug(f"Checking for updates to {engine_class.name()}") - latest_version = engine_class.downloader().find_most_recent_version() + def is_engine_update_available(cls, engine_class, ignore_system_installs=False): + logger.debug(f"Checking for updates to {engine_class.name()}") + latest_version = engine_class.downloader().find_most_recent_version() - if not latest_version: - logger.warning(f"Could not find most recent version of {engine.name()} to download") - return + if not latest_version: + logger.warning(f"Could not find most recent version of {engine_class.name()} to download") + return None - version_num = latest_version.get('version') - if cls.is_version_downloaded(engine_class.name(), version_num): - logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded") - return + version_num = latest_version.get('version') + if cls.is_version_downloaded(engine_class.name(), version_num, ignore_system=ignore_system_installs): + logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded") + return None - # download the engine - logger.info(f"Downloading latest version of {engine_class.name()} ({version_num})...") - cls.download_engine(engine=engine_class.name(), version=version_num, background=True) + return latest_version - logger.info(f"Checking for updates for render engines...") - threads = [] - for engine in cls.supported_engines(): - if engine.downloader(): - thread = threading.Thread(target=engine_update_task, args=(engine,)) - threads.append(thread) - thread.start() @classmethod def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None): @@ -303,18 +310,23 @@ class EngineDownloadWorker(threading.Thread): self.version = version self.system_os = system_os self.cpu = cpu + self.percent_complete = 0 + + def _update_progress(self, current_progress): + self.percent_complete = current_progress def run(self): try: - existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu) + existing_download = EngineManager.is_version_downloaded(self.engine, self.version, self.system_os, self.cpu, + ignore_system=True) if existing_download: logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists") return existing_download # Get the appropriate downloader class based on the engine type - EngineManager.engine_with_name(self.engine).downloader().download_engine( - self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu, - timeout=300) + downloader = EngineManager.engine_with_name(self.engine).downloader() + downloader.download_engine( self.version, download_location=EngineManager.engines_path, + system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress) except Exception as e: logger.error(f"Error in download worker: {e}") finally: diff --git a/src/engines/ffmpeg/ffmpeg_downloader.py b/src/engines/ffmpeg/ffmpeg_downloader.py index 8f69c44..7da39c7 100644 --- a/src/engines/ffmpeg/ffmpeg_downloader.py +++ b/src/engines/ffmpeg/ffmpeg_downloader.py @@ -97,7 +97,7 @@ class FFMPEGDownloader(EngineDownloader): 'windows': cls.__get_windows_versions} if not versions_per_os.get(system_os): logger.error(f"Cannot find version list for {system_os}") - return + return None results = [] all_versions = versions_per_os[system_os]() @@ -144,7 +144,7 @@ class FFMPEGDownloader(EngineDownloader): return None @classmethod - def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120): + def download_engine(cls, version, download_location, system_os=None, cpu=None, timeout=120, progress_callback=None): system_os = system_os or current_system_os() cpu = cpu or current_system_cpu() @@ -152,7 +152,7 @@ class FFMPEGDownloader(EngineDownloader): found_version = [item for item in cls.all_versions(system_os, cpu) if item['version'] == version] if not found_version: logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}") - return + return None # Platform specific naming cleanup remote_url = cls.__get_remote_url_for_version(version=version, system_os=system_os, cpu=cpu) @@ -162,7 +162,8 @@ class FFMPEGDownloader(EngineDownloader): # Download and extract try: logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}") - cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout) + cls.download_and_extract_app(remote_url=remote_url, download_location=download_location, timeout=timeout, + progress_callback=progress_callback) # naming cleanup to match existing naming convention output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}') diff --git a/src/ui/engine_browser.py b/src/ui/engine_browser.py index aba78ed..ba8cba5 100644 --- a/src/ui/engine_browser.py +++ b/src/ui/engine_browser.py @@ -128,18 +128,18 @@ class EngineBrowserWindow(QMainWindow): self.launch_button.setEnabled(is_localhost(self.hostname)) def update_download_status(self): - running_tasks = [x for x in EngineManager.download_tasks if x.is_alive()] + running_tasks = EngineManager.active_downloads() hide_progress = not bool(running_tasks) self.progress_bar.setHidden(hide_progress) self.progress_label.setHidden(hide_progress) # Update the status labels - if len(EngineManager.download_tasks) == 0: + if len(running_tasks) == 0: new_status = "" - elif len(EngineManager.download_tasks) == 1: - task = EngineManager.download_tasks[0] + elif len(running_tasks) == 1: + task = running_tasks[0] new_status = f"Downloading {task.engine.capitalize()} {task.version}..." else: - new_status = f"Downloading {len(EngineManager.download_tasks)} engines..." + new_status = f"Downloading {len(running_tasks)} engines..." self.progress_label.setText(new_status) def launch_button_click(self): diff --git a/src/ui/main_window.py b/src/ui/main_window.py index aad2e93..601787e 100644 --- a/src/ui/main_window.py +++ b/src/ui/main_window.py @@ -91,12 +91,13 @@ class MainWindow(QMainWindow): self.setup_ui(main_layout) - self.create_toolbars() - # Add Widgets to Window + # self.custom_menu_bar = self.setMenuBar(MenuBar(self)) self.setStatusBar(StatusBar(self)) + self.create_toolbars() + # start background update self.bg_update_thread = QThread() self.bg_update_thread.run = self.__background_update @@ -504,9 +505,9 @@ class MainWindow(QMainWindow): # Top Toolbar Buttons self.topbar.add_button( - "Console", f"{resources_directory}/Console.png", self.open_console_window) + "Settings", f"{resources_directory}/Gear.png", self.menuBar().show_settings) self.topbar.add_button( - "Engines", f"{resources_directory}/SoftwareInstaller.png", self.engine_browser) + "Console", f"{resources_directory}/Console.png", self.open_console_window) self.topbar.add_separator() self.topbar.add_button( "Stop Job", f"{resources_directory}/StopSign.png", self.stop_job) diff --git a/src/ui/settings_window.py b/src/ui/settings_window.py new file mode 100644 index 0000000..236afa3 --- /dev/null +++ b/src/ui/settings_window.py @@ -0,0 +1,554 @@ +import os +import socket +from datetime import datetime +from pathlib import Path + +import humanize +from PyQt6 import QtCore +from PyQt6.QtCore import Qt, QSettings, pyqtSignal as Signal, QThread, pyqtSignal, QTimer +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QApplication, QMainWindow, QListWidget, QListWidgetItem, QStackedWidget, QVBoxLayout, \ + QWidget, QLabel, QCheckBox, QLineEdit, \ + QPushButton, QHBoxLayout, QGroupBox, QTableWidget, QAbstractItemView, QTableWidgetItem, QHeaderView, \ + QMessageBox, QProgressBar + +from src.api.server_proxy import RenderServerProxy +from src.engines.engine_manager import EngineManager +from src.utilities.config import Config +from src.utilities.misc_helper import launch_url, system_safe_path +from src.version import APP_AUTHOR, APP_NAME + +settings = QSettings(APP_AUTHOR, APP_NAME) + +class GetEngineInfoWorker(QThread): + """ + The GetEngineInfoWorker class fetches engine information from a server in a background thread. + + Attributes: + done: A signal emitted when the engine information is retrieved. + + Methods: + run(self): Fetches engine information from the server. + """ + done = pyqtSignal(object) # emits the result when finished + + def __init__(self, parent=None): + super().__init__(parent) + self.parent = parent + + def run(self): + data = RenderServerProxy(socket.gethostname()).get_engine_info() + self.done.emit(data) + +class SettingsWindow(QMainWindow): + """ + The SettingsWindow class provides a user interface for managing engine settings. + """ + + def __init__(self): + super().__init__() + + self.engine_download_progress_bar = None + self.engines_last_update_label = None + self.check_for_engine_updates_checkbox = None + self.delete_engine_button = None + self.launch_engine_button = None + self.show_password_button = None + self.network_password_line = None + self.enable_network_password_checkbox = None + self.check_for_new_engines_button = None + + if not EngineManager.engines_path: # fix issue where sometimes path was not set + EngineManager.engines_path = system_safe_path( + os.path.join(os.path.join(os.path.expanduser(Config.upload_folder), + 'engines'))) + + self.installed_engines_table = None + + self.setWindowTitle("Settings") + + # Create the main layout + main_layout = QVBoxLayout() + + # Create the sidebar (QListWidget) for navigation + self.sidebar = QListWidget() + self.sidebar.setFixedWidth(150) + + # Set the icon size + self.sidebar.setIconSize(QtCore.QSize(32, 32)) # Increase the icon size to 32x32 pixels + + # Adjust the font size for the sidebar items + font = self.sidebar.font() + font.setPointSize(12) # Increase the font size + self.sidebar.setFont(font) + + # Add items with icons to the sidebar + resources_dir = os.path.join(Path(__file__).resolve().parent.parent.parent, 'resources') + self.add_sidebar_item("General", os.path.join(resources_dir, "Gear.png")) + self.add_sidebar_item("Server", os.path.join(resources_dir, "Server.png")) + self.add_sidebar_item("Engines", os.path.join(resources_dir, "Blender.png")) + self.sidebar.setCurrentRow(0) + + # Create the stacked widget to hold different settings pages + self.stacked_widget = QStackedWidget() + + # Create pages for each section + general_page = self.create_general_page() + network_page = self.create_network_page() + engines_page = self.create_engines_page() + + # Add pages to the stacked widget + self.stacked_widget.addWidget(general_page) + self.stacked_widget.addWidget(network_page) + self.stacked_widget.addWidget(engines_page) + + # Connect the sidebar to the stacked widget + self.sidebar.currentRowChanged.connect(self.stacked_widget.setCurrentIndex) + + # Create a horizontal layout to hold the sidebar and stacked widget + content_layout = QHBoxLayout() + content_layout.addWidget(self.sidebar) + content_layout.addWidget(self.stacked_widget) + + # Add the content layout to the main layout + main_layout.addLayout(content_layout) + + # Add the "OK" button at the bottom + ok_button = QPushButton("OK") + ok_button.clicked.connect(self.close) + ok_button.setFixedWidth(80) + ok_button.setDefault(True) + main_layout.addWidget(ok_button, alignment=Qt.AlignmentFlag.AlignRight) + + # Create a central widget and set the layout + central_widget = QWidget() + central_widget.setLayout(main_layout) + self.setCentralWidget(central_widget) + + self.setMinimumSize(700, 400) + + # timers for background download UI updates + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_engine_download_status) + + def add_sidebar_item(self, name, icon_path): + """Add an item with an icon to the sidebar.""" + item = QListWidgetItem(QIcon(icon_path), name) + self.sidebar.addItem(item) + + def create_general_page(self): + """Create the General settings page.""" + page = QWidget() + layout = QVBoxLayout() + + # Startup Settings Group + startup_group = QGroupBox("Startup Settings") + startup_layout = QVBoxLayout() + # startup_layout.addWidget(QCheckBox("Start application on system startup")) + check_for_updates_checkbox = QCheckBox("Check for updates automatically") + check_for_updates_checkbox.setChecked(settings.value("auto_check_for_updates", True, type=bool)) + check_for_updates_checkbox.stateChanged.connect(lambda state: settings.setValue("auto_check_for_updates", bool(state))) + startup_layout.addWidget(check_for_updates_checkbox) + startup_group.setLayout(startup_layout) + + # Local Files Group + data_path = Path(Config.upload_folder).expanduser() + path_size = sum(f.stat().st_size for f in Path(data_path).rglob('*') if f.is_file()) + database_group = QGroupBox("Local Files") + database_layout = QVBoxLayout() + database_layout.addWidget(QLabel(f"Local Directory: {data_path}")) + database_layout.addWidget(QLabel(f"Size: {humanize.naturalsize(path_size, binary=True)}")) + open_database_path_button = QPushButton("Open Directory") + open_database_path_button.clicked.connect(lambda: launch_url(data_path)) + open_database_path_button.setFixedWidth(200) + database_layout.addWidget(open_database_path_button) + database_group.setLayout(database_layout) + + + # Render Settings Group + render_settings_group = QGroupBox("Render Engine Settings") + render_settings_layout = QVBoxLayout() + render_settings_layout.addWidget(QLabel("Restrict to render nodes with same:")) + require_same_engine_checkbox = QCheckBox("Renderer Version") + require_same_engine_checkbox.setChecked(settings.value("render_require_same_engine_version", False, type=bool)) + require_same_engine_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_engine_version", bool(state))) + render_settings_layout.addWidget(require_same_engine_checkbox) + require_same_cpu_checkbox = QCheckBox("CPU Architecture") + require_same_cpu_checkbox.setChecked(settings.value("render_require_same_cpu_type", False, type=bool)) + require_same_cpu_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_cpu_type", bool(state))) + render_settings_layout.addWidget(require_same_cpu_checkbox) + require_same_os_checkbox = QCheckBox("Operating System") + require_same_os_checkbox.setChecked(settings.value("render_require_same_os", False, type=bool)) + require_same_os_checkbox.stateChanged.connect(lambda state: settings.setValue("render_require_same_os", bool(state))) + render_settings_layout.addWidget(require_same_os_checkbox) + render_settings_group.setLayout(render_settings_layout) + + layout.addWidget(startup_group) + layout.addWidget(database_group) + layout.addWidget(render_settings_group) + + layout.addStretch() # Add a stretch to push content to the top + page.setLayout(layout) + return page + + def create_network_page(self): + """Create the Network settings page.""" + page = QWidget() + layout = QVBoxLayout() + + # Sharing Settings Group + sharing_group = QGroupBox("Sharing Settings") + sharing_layout = QVBoxLayout() + + enable_sharing_checkbox = QCheckBox("Enable other computers on the network to render to this machine") + enable_sharing_checkbox.setChecked(settings.value("enable_network_sharing", False, type=bool)) + enable_sharing_checkbox.stateChanged.connect(self.toggle_render_sharing) + sharing_layout.addWidget(enable_sharing_checkbox) + + password_enabled = (settings.value("enable_network_sharing", False, type=bool) and + settings.value("enable_network_password", False, type=bool)) + + password_layout = QHBoxLayout() + password_layout.setContentsMargins(0, 0, 0, 0) + self.enable_network_password_checkbox = QCheckBox("Enable network password:") + self.enable_network_password_checkbox.setChecked(settings.value("enable_network_password", False, type=bool)) + self.enable_network_password_checkbox.stateChanged.connect(self.enable_network_password_changed) + self.enable_network_password_checkbox.setEnabled(settings.value("enable_network_sharing", False, type=bool)) + sharing_layout.addWidget(self.enable_network_password_checkbox) + self.network_password_line = QLineEdit() + self.network_password_line.setPlaceholderText("Enter a password") + self.network_password_line.setEchoMode(QLineEdit.EchoMode.Password) + self.network_password_line.setEnabled(password_enabled) + password_layout.addWidget(self.network_password_line) + self.show_password_button = QPushButton("Show") + self.show_password_button.setEnabled(password_enabled) + self.show_password_button.clicked.connect(self.show_password_button_pressed) + password_layout.addWidget(self.show_password_button) + sharing_layout.addLayout(password_layout) + + sharing_group.setLayout(sharing_layout) + + layout.addWidget(sharing_group) + + layout.addStretch() # Add a stretch to push content to the top + page.setLayout(layout) + return page + + def toggle_render_sharing(self, enable_sharing): + settings.setValue("enable_network_sharing", enable_sharing) + self.enable_network_password_checkbox.setEnabled(enable_sharing) + enable_password = enable_sharing and settings.value("enable_network_password", False, type=bool) + self.network_password_line.setEnabled(enable_password) + self.show_password_button.setEnabled(enable_password) + + def enable_network_password_changed(self, new_value): + settings.setValue("enable_network_password", new_value) + self.network_password_line.setEnabled(new_value) + self.show_password_button.setEnabled(new_value) + + def show_password_button_pressed(self): + # toggle showing / hiding the password + show_pass = self.show_password_button.text() == "Show" + self.show_password_button.setText("Hide" if show_pass else "Show") + self.network_password_line.setEchoMode(QLineEdit.EchoMode.Normal if show_pass else QLineEdit.EchoMode.Password) + + def create_engines_page(self): + """Create the Engines settings page.""" + page = QWidget() + layout = QVBoxLayout() + + # Installed Engines Group + installed_group = QGroupBox("Installed Engines") + installed_layout = QVBoxLayout() + + # Setup table + self.installed_engines_table = EngineTableWidget() + self.installed_engines_table.row_selected.connect(self.engine_table_selected) + installed_layout.addWidget(self.installed_engines_table) + + # Ignore system installs + engine_ignore_system_installs_checkbox = QCheckBox("Ignore system installs") + engine_ignore_system_installs_checkbox.setChecked(settings.value("engines_ignore_system_installs", False, type=bool)) + engine_ignore_system_installs_checkbox.stateChanged.connect(self.change_ignore_system_installs) + installed_layout.addWidget(engine_ignore_system_installs_checkbox) + + # Engine Launch / Delete buttons + installed_buttons_layout = QHBoxLayout() + self.launch_engine_button = QPushButton("Launch") + self.launch_engine_button.setEnabled(False) + self.launch_engine_button.clicked.connect(self.launch_selected_engine) + self.delete_engine_button = QPushButton("Delete") + self.delete_engine_button.setEnabled(False) + self.delete_engine_button.clicked.connect(self.delete_selected_engine) + + installed_buttons_layout.addWidget(self.launch_engine_button) + installed_buttons_layout.addWidget(self.delete_engine_button) + installed_layout.addLayout(installed_buttons_layout) + installed_group.setLayout(installed_layout) + + # Engine Updates Group + engine_updates_group = QGroupBox("Auto-Install") + engine_updates_layout = QVBoxLayout() + + engine_download_layout = QHBoxLayout() + engine_download_layout.addWidget(QLabel("Enable Downloads for:")) + + at_least_one_downloadable = False + for engine in EngineManager.downloadable_engines(): + engine_download_check = QCheckBox(engine.name()) + is_checked = settings.value(f"engine_download-{engine.name()}", False, type=bool) + at_least_one_downloadable |= is_checked + engine_download_check.setChecked(is_checked) + # Capture the checkbox correctly using a default argument in lambda + engine_download_check.clicked.connect( + lambda state, checkbox=engine_download_check: self.engine_download_settings_changed(state, checkbox.text()) + ) + engine_download_layout.addWidget(engine_download_check) + + engine_updates_layout.addLayout(engine_download_layout) + + self.check_for_engine_updates_checkbox = QCheckBox("Check for new versions on launch") + self.check_for_engine_updates_checkbox.setChecked(settings.value('check_for_engine_updates_on_launch', True, type=bool)) + self.check_for_engine_updates_checkbox.setEnabled(at_least_one_downloadable) + self.check_for_engine_updates_checkbox.stateChanged.connect( + lambda state: settings.setValue("check_for_engine_updates_on_launch", bool(state))) + engine_updates_layout.addWidget(self.check_for_engine_updates_checkbox) + self.engines_last_update_label = QLabel() + self.update_last_checked_label() + self.engines_last_update_label.setEnabled(at_least_one_downloadable) + engine_updates_layout.addWidget(self.engines_last_update_label) + self.engine_download_progress_bar = QProgressBar() + engine_updates_layout.addWidget(self.engine_download_progress_bar) + self.engine_download_progress_bar.setHidden(True) + self.check_for_new_engines_button = QPushButton("Check for New Versions...") + self.check_for_new_engines_button.setEnabled(at_least_one_downloadable) + self.check_for_new_engines_button.clicked.connect(self.check_for_new_engines) + engine_updates_layout.addWidget(self.check_for_new_engines_button) + engine_updates_group.setLayout(engine_updates_layout) + + layout.addWidget(installed_group) + layout.addWidget(engine_updates_group) + + layout.addStretch() # Add a stretch to push content to the top + page.setLayout(layout) + return page + + def change_ignore_system_installs(self, value): + settings.setValue("engines_ignore_system_installs", bool(value)) + self.installed_engines_table.update_engines_table() + + def update_last_checked_label(self): + """Retrieve the last check timestamp and return a human-friendly string.""" + last_checked_str = settings.value("engines_last_update_time", None) + if not last_checked_str: + time_string = "Never" + else: + last_checked_dt = datetime.fromisoformat(last_checked_str) + now = datetime.now() + time_string = humanize.naturaltime(now - last_checked_dt) + self.engines_last_update_label.setText(f"Last Updated: {time_string}") + + def engine_download_settings_changed(self, state, engine_name): + settings.setValue(f"engine_download-{engine_name}", state) + + at_least_one_downloadable = False + for engine in EngineManager.downloadable_engines(): + at_least_one_downloadable |= settings.value(f"engine_download-{engine.name()}", False, type=bool) + self.check_for_new_engines_button.setEnabled(at_least_one_downloadable) + self.check_for_engine_updates_checkbox.setEnabled(at_least_one_downloadable) + self.engines_last_update_label.setEnabled(at_least_one_downloadable) + + def delete_selected_engine(self): + engine_info = self.installed_engines_table.selected_engine_data() + reply = QMessageBox.question(self, f"Delete {engine_info['engine']} {engine_info['version']}?", + f"Do you want to delete {engine_info['engine']} {engine_info['version']}?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + + if reply is not QMessageBox.StandardButton.Yes: + return + + delete_result = EngineManager.delete_engine_download(engine_info.get('engine'), + engine_info.get('version'), + engine_info.get('system_os'), + engine_info.get('cpu')) + self.installed_engines_table.update_engines_table(use_cached=False) + if delete_result: + QMessageBox.information(self, f"{engine_info['engine']} {engine_info['version']} Deleted", + f"{engine_info['engine']} {engine_info['version']} deleted successfully", + QMessageBox.StandardButton.Ok) + else: + QMessageBox.warning(self, f"Unknown Error", + f"Unknown error while deleting {engine_info['engine']} {engine_info['version']}.", + QMessageBox.StandardButton.Ok) + + def launch_selected_engine(self): + engine_info = self.installed_engines_table.selected_engine_data() + if engine_info: + launch_url(engine_info['path']) + + def engine_table_selected(self): + engine_data = self.installed_engines_table.selected_engine_data() + if engine_data: + self.launch_engine_button.setEnabled(bool(engine_data.get('path') or True)) + self.delete_engine_button.setEnabled(engine_data.get('type') == 'managed') + else: + self.launch_engine_button.setEnabled(False) + self.delete_engine_button.setEnabled(False) + + def check_for_new_engines(self): + + ignore_system = settings.value("engines_ignore_system_installs", False, type=bool) + messagebox_shown = False + for engine in EngineManager.downloadable_engines(): + if settings.value(f'engine_download-{engine.name()}', False, type=bool): + result = EngineManager.is_engine_update_available(engine, ignore_system_installs=ignore_system) + if result: + result['name'] = engine.name() + msg_box = QMessageBox() + msg_box.setWindowTitle(f"{result['name']} ({result['version']}) Available") + msg_box.setText(f"A new version of {result['name']} is available ({result['version']}).\n\n" + f"Would you like to download it now?") + msg_box.setIcon(QMessageBox.Icon.Question) + msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + msg_result = msg_box.exec() + messagebox_shown = True + if msg_result == QMessageBox.StandardButton.Yes: + EngineManager.download_engine(engine=engine.name(), version=result['version'], background=True, + ignore_system=ignore_system) + self.engine_download_progress_bar.setHidden(False) + self.engine_download_progress_bar.setValue(0) + self.engine_download_progress_bar.setMaximum(100) + self.check_for_new_engines_button.setEnabled(False) + self.timer.start(1000) + + if not messagebox_shown: + msg_box = QMessageBox() + msg_box.setWindowTitle("No Updates Available") + msg_box.setText("No Updates Available.") + msg_box.setIcon(QMessageBox.Icon.Information) + msg_box.setStandardButtons(QMessageBox.StandardButton.Ok) + msg_box.exec() + + settings.setValue("engines_last_update_time", datetime.now().isoformat()) + self.update_engine_download_status() + + def update_engine_download_status(self): + + running_tasks = EngineManager.active_downloads() + if not running_tasks: + self.timer.stop() + self.engine_download_progress_bar.setHidden(True) + self.installed_engines_table.update_engines_table(use_cached=False) + self.update_last_checked_label() + self.check_for_new_engines_button.setEnabled(True) + return + + percent_complete = int(running_tasks[0].percent_complete * 100) + self.engine_download_progress_bar.setValue(percent_complete) + if percent_complete == 100: + status_update = f"Installing {running_tasks[0].engine.capitalize()} {running_tasks[0].version}..." + else: + status_update = f"Downloading {running_tasks[0].engine.capitalize()} {running_tasks[0].version}..." + self.engines_last_update_label.setText(status_update) + + +class EngineTableWidget(QWidget): + """ + The EngineTableWidget class displays a table of installed engines. + + Attributes: + table: A table widget displaying engine information. + + Methods: + on_selection_changed(self): Emits a signal when the user selects a different row in the table. + """ + + row_selected = Signal() + + def __init__(self): + super().__init__() + + self.__get_engine_info_worker = None + self.table = QTableWidget(0, 4) + self.table.setHorizontalHeaderLabels(["Engine", "Version", "Type", "Path"]) + self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.table.verticalHeader().setVisible(False) + # self.table_widget.itemSelectionChanged.connect(self.engine_picked) + self.table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) + self.table.selectionModel().selectionChanged.connect(self.on_selection_changed) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(self.table) + + self.raw_server_data = None + + def showEvent(self, event): + """Runs when the widget is about to be shown.""" + self.update_engines_table() + super().showEvent(event) # Ensure normal event processing + + def engine_data_ready(self, raw_server_data): + self.raw_server_data = raw_server_data + self.update_engines_table() + + def update_engines_table(self, use_cached=True): + if not self.raw_server_data or not use_cached: + self.__get_engine_info_worker = GetEngineInfoWorker(self) + self.__get_engine_info_worker.done.connect(self.engine_data_ready) + self.__get_engine_info_worker.start() + if not self.raw_server_data: + return + + table_data = [] # convert the data into a flat list + for _, engine_data in self.raw_server_data.items(): + table_data.extend(engine_data['versions']) + + if settings.value("engines_ignore_system_installs", False, type=bool): + table_data = [x for x in table_data if x['type'] != 'system'] + + self.table.clear() + self.table.setRowCount(len(table_data)) + self.table.setColumnCount(4) + + self.table.setHorizontalHeaderLabels(['Engine', 'Version', 'Type', 'Path']) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) + self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed) + self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeMode.Stretch) + + for row, engine in enumerate(table_data): + self.table.setItem(row, 0, QTableWidgetItem(engine['engine'])) + self.table.setItem(row, 1, QTableWidgetItem(engine['version'])) + self.table.setItem(row, 2, QTableWidgetItem(engine['type'])) + self.table.setItem(row, 3, QTableWidgetItem(engine['path'])) + + self.table.selectRow(0) + + def selected_engine_data(self): + """Returns the data from the selected row as a dictionary.""" + row = self.table.currentRow() # Get the selected row index + + if row < 0 or not len(self.table.selectedItems()): # No row selected + return None + + data = { + "engine": self.table.item(row, 0).text(), + "version": self.table.item(row, 1).text(), + "type": self.table.item(row, 2).text(), + "path": self.table.item(row, 3).text(), + } + + return data + + def on_selection_changed(self): + self.row_selected.emit() + + + +if __name__ == "__main__": + app = QApplication([]) + window = SettingsWindow() + window.show() + app.exec() \ No newline at end of file diff --git a/src/ui/widgets/menubar.py b/src/ui/widgets/menubar.py index 71df80d..b8e97c0 100644 --- a/src/ui/widgets/menubar.py +++ b/src/ui/widgets/menubar.py @@ -14,6 +14,8 @@ class MenuBar(QMenuBar): def __init__(self, parent=None) -> None: super().__init__(parent) + self.settings_window = None + # setup menus file_menu = self.addMenu("File") # edit_menu = self.addMenu("Edit") @@ -30,7 +32,7 @@ class MenuBar(QMenuBar): settings_action = QAction("Settings...", self) settings_action.triggered.connect(self.show_settings) settings_action.setShortcut(f'Ctrl+,') - # file_menu.addAction(settings_action) # todo: enable once we have a setting screen + file_menu.addAction(settings_action) # exit exit_action = QAction('&Exit', self) exit_action.setShortcut('Ctrl+Q') @@ -49,7 +51,9 @@ class MenuBar(QMenuBar): self.parent().new_job() def show_settings(self): - pass + from src.ui.settings_window import SettingsWindow + self.settings_window = SettingsWindow() + self.settings_window.show() @staticmethod def show_about(): diff --git a/src/ui/widgets/statusbar.py b/src/ui/widgets/statusbar.py index 6e6e16e..05809d1 100644 --- a/src/ui/widgets/statusbar.py +++ b/src/ui/widgets/statusbar.py @@ -35,12 +35,13 @@ class StatusBar(QStatusBar): try: # update status label - get download status new_status = proxy.status() - if EngineManager.download_tasks: - if len(EngineManager.download_tasks) == 1: - task = EngineManager.download_tasks[0] + active_downloads = EngineManager.active_downloads() + if active_downloads: + if len(active_downloads) == 1: + task = active_downloads[0] new_status = f"{new_status} | Downloading {task.engine.capitalize()} {task.version}..." else: - new_status = f"{new_status} | Downloading {len(EngineManager.download_tasks)} engines" + new_status = f"{new_status} | Downloading {len(active_downloads)} engines" self.messageLabel.setText(new_status) # update status image diff --git a/src/utilities/misc_helper.py b/src/utilities/misc_helper.py index f433e74..c68fdec 100644 --- a/src/utilities/misc_helper.py +++ b/src/utilities/misc_helper.py @@ -7,6 +7,7 @@ import shutil import socket import string import subprocess +import sys from datetime import datetime logger = logging.getLogger() @@ -139,6 +140,40 @@ def current_system_cpu(): # convert all x86 64 to "x64" return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', 'x64') +def current_system_cpu_brand(): + """Fast cross-platform CPU brand string""" + if sys.platform.startswith('darwin'): # macOS + try: + return subprocess.check_output(['sysctl', '-n', 'machdep.cpu.brand_string']).decode().strip() + except Exception: + pass + elif sys.platform.startswith('win'): # Windows + from winreg import HKEY_LOCAL_MACHINE, OpenKey, QueryValueEx + try: + # Open the registry key where Windows stores the CPU name + key = OpenKey(HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\0") + # The value name is "ProcessorNameString" + value, _ = QueryValueEx(key, "ProcessorNameString") + return value.strip() # Usually perfect, with full marketing name + except Exception: + # Fallback: sometimes the key is under a different index, try 1 + try: + key = OpenKey(HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\CentralProcessor\1") + value, _ = QueryValueEx(key, "ProcessorNameString") + return value.strip() + except Exception: + return "Unknown CPU" + elif sys.platform.startswith('linux'): + try: + with open('/proc/cpuinfo') as f: + for line in f: + if line.startswith('model name'): + return line.split(':', 1)[1].strip() + except Exception: + pass + + # Ultimate fallback + return platform.processor() or 'Unknown CPU' def resources_dir(): resource_environment_path = os.environ.get('RESOURCEPATH', None) @@ -284,7 +319,11 @@ def get_gpu_info(): def get_macos_gpu_info(): """Get GPU info on macOS (works with Apple Silicon)""" try: - result = subprocess.run(['system_profiler', 'SPDisplaysDataType', '-json'], + if current_system_cpu() == "arm64": + # don't bother with system_profiler with Apple ARM - we know its integrated + return [{'name': current_system_cpu_brand(), 'memory': 'Integrated'}] + + result = subprocess.run(['system_profiler', 'SPDisplaysDataType', '-detailLevel', 'mini', '-json'], capture_output=True, text=True, timeout=5) data = json.loads(result.stdout) @@ -296,7 +335,7 @@ def get_gpu_info(): 'name': display.get('sppci_model', 'Unknown GPU'), 'memory': display.get('sppci_vram', 'Integrated'), }) - return gpus if gpus else [{'name': 'Apple Silicon GPU', 'memory': 'Integrated'}] + return gpus if gpus else [{'name': 'Apple GPU', 'memory': 'Integrated'}] except Exception as e: print(f"Failed to get macOS GPU info: {e}") return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]