Settings Window (#124)

* Initial commit for settings window

* More WIP for the Settings panel

* Added Local Files section to Settings

* More WIP on Settings

* Add ability to ignore system builds

* Improvements to Launch and Delete buttons

* Fix issue where icons were not loading

* Network password settings WIP

* Update label

* Import and naming fixes

* Speed improvements to launch

* Update requirements.txt

* Update Windows CPU name lookup

* Add missing default values to a few settings

* More settings fixes

* Fix Windows Path issue

* Added hard types for getting settings values

* More UI cleanup

* Correctly refresh Engines list after downloading new engine

* Improve downloader with UI progress

* More download improvements

* Add Settings Button to Toolbar
This commit is contained in:
2025-12-28 12:33:29 -06:00
committed by GitHub
parent daf445ee9e
commit 4704806472
13 changed files with 733 additions and 129 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@
/venv/ /venv/
.env .env
venv/ venv/
/.eggs/
/.ai/
/.github/

View File

@@ -1,41 +1,20 @@
PyQt6>=6.6.1 PyQt6>=6.7.0
psutil>=5.9.8 psutil>=5.9.8
requests>=2.31.0 requests>=2.32.2
Pillow>=10.2.0 Pillow>=10.3.0
PyYAML>=6.0.1 PyYAML>=6.0.1
flask>=3.0.2 flask>=3.0.3
tqdm>=4.66.2 tqdm>=4.66.4
werkzeug>=3.0.1 werkzeug>=3.0.3
Pypubsub>=4.0.3 Pypubsub>=4.0.3
zeroconf>=0.131.0 zeroconf>=0.132.2
SQLAlchemy>=2.0.25 SQLAlchemy>=2.0.30
plyer>=2.1.0 plyer>=2.1.0
pytz>=2023.3.post1 rich>=13.7.1
future>=0.18.3 setuptools>=70.0.0
rich>=13.7.0 py-cpuinfo>=9.0.0
pytest>=8.0.0 requests-toolbelt>=1.0.0
numpy>=1.26.3 PyQt6-sip>=13.6.0
setuptools>=69.0.3 humanize>=4.12.1
pandas>=2.2.0 macholib>=1.16.3
matplotlib>=3.8.2 altgraph>=0.17.4
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

View File

@@ -17,7 +17,7 @@ from src.engines.engine_manager import EngineManager
from src.render_queue import RenderQueue from src.render_queue import RenderQueue
from src.utilities.config import Config from src.utilities.config import Config
from src.utilities.misc_helper import (get_gpu_info, system_safe_path, current_system_cpu, current_system_os, 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.utilities.zeroconf_server import ZeroconfServer
from src.version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER 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() ServerProxyManager.subscribe_to_listener()
DistributedJobManager.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 # get hostname
local_hostname = socket.gethostname() local_hostname = socket.gethostname()
@@ -108,7 +104,7 @@ def start_server(skip_updates=False) -> int:
# start zeroconf server # start zeroconf server
ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number) ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number)
ZeroconfServer.properties = {'system_cpu': current_system_cpu(), 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_cpu_cores': multiprocessing.cpu_count(),
'system_os': current_system_os(), 'system_os': current_system_os(),
'system_os_version': current_system_os_version(), 'system_os_version': current_system_os_version(),
@@ -118,6 +114,11 @@ def start_server(skip_updates=False) -> int:
ZeroconfServer.start() ZeroconfServer.start()
logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}") logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}")
RenderQueue.start() # Start evaluating the render queue 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() api_server.join()
except KeyboardInterrupt: except KeyboardInterrupt:

View File

@@ -8,8 +8,7 @@ from src.engines.blender.blender_engine import Blender
from src.engines.core.base_downloader import EngineDownloader from src.engines.core.base_downloader import EngineDownloader
from src.utilities.misc_helper import current_system_os, current_system_cpu from src.utilities.misc_helper import current_system_os, current_system_cpu
# url = "https://download.blender.org/release/" url = "https://download.blender.org/release/"
url = "https://ftp.nluug.nl/pub/graphics/blender/release/" # much faster mirror for testing
logger = logging.getLogger() logger = logging.getLogger()
supported_formats = ['.zip', '.tar.xz', '.dmg'] supported_formats = ['.zip', '.tar.xz', '.dmg']
@@ -88,8 +87,8 @@ class BlenderDownloader(EngineDownloader):
threads = [] threads = []
results = [[] for _ in majors] results = [[] for _ in majors]
def thread_function(major_version, index, system_os, cpu): def thread_function(major_version, index, system_os_t, cpu_t):
results[index] = cls.__get_minor_versions(major_version, system_os, cpu) results[index] = cls.__get_minor_versions(major_version, system_os_t, cpu_t)
for i, m in enumerate(majors): for i, m in enumerate(majors):
thread = threading.Thread(target=thread_function, args=(m, i, system_os, cpu)) thread = threading.Thread(target=thread_function, args=(m, i, system_os, cpu))
@@ -126,7 +125,7 @@ class BlenderDownloader(EngineDownloader):
return None return None
@classmethod @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() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() 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 minor_versions = [x for x in cls.__get_minor_versions(major_version, system_os, cpu) if
x['version'] == version] x['version'] == version]
cls.download_and_extract_app(remote_url=minor_versions[0]['url'], download_location=download_location, 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: except IndexError:
logger.error("Cannot find requested engine") logger.error("Cannot find requested engine")

View File

@@ -74,7 +74,7 @@ class EngineDownloader:
raise NotImplementedError(f"version_is_available_to_download not implemented for {cls.__class__.__name__}") raise NotImplementedError(f"version_is_available_to_download not implemented for {cls.__class__.__name__}")
@classmethod @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. """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 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. 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. 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. 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: Raises:
NotImplementedError: If the method is not overridden in a subclass. NotImplementedError: If the method is not overridden in a subclass.
@@ -125,7 +126,7 @@ class EngineDownloader:
# -------------------------------------------- # --------------------------------------------
@classmethod @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. """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, 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. remote_url (str): The URL of the application to download.
download_location (str): The directory where the application should be extracted. 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. 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: Returns:
str: The path to the directory where the application was extracted. str: The path to the directory where the application was extracted.
@@ -154,6 +156,8 @@ class EngineDownloader:
and return without downloading or extracting. and return without downloading or extracting.
- Temporary files created during the download process are cleaned up after completion. - Temporary files created during the download process are cleaned up after completion.
""" """
if progress_callback:
progress_callback(0)
# Create a temp download directory # Create a temp download directory
temp_download_dir = tempfile.mkdtemp() temp_download_dir = tempfile.mkdtemp()
@@ -166,7 +170,7 @@ class EngineDownloader:
if os.path.exists(os.path.join(download_location, output_dir_name)): if os.path.exists(os.path.join(download_location, output_dir_name)):
logger.error(f"Engine download for {output_dir_name} already exists") logger.error(f"Engine download for {output_dir_name} already exists")
return return None
if not os.path.exists(temp_downloaded_file_path): if not os.path.exists(temp_downloaded_file_path):
# Make a GET request to the URL with stream=True to enable streaming # 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) progress_bar = tqdm(total=file_size, unit="B", unit_scale=True)
# Open a file for writing in binary mode # Open a file for writing in binary mode
total_saved = 0
with open(temp_downloaded_file_path, "wb") as file: with open(temp_downloaded_file_path, "wb") as file:
for chunk in response.iter_content(chunk_size=1024): for chunk in response.iter_content(chunk_size=1024):
if chunk: if chunk:
# Write the chunk to the file # Write the chunk to the file
file.write(chunk) file.write(chunk)
total_saved += len(chunk)
# Update the progress bar # Update the progress bar
progress_bar.update(len(chunk)) progress_bar.update(len(chunk))
if progress_callback:
percent = float(total_saved) / float(file_size)
progress_callback(percent)
# Close the progress bar # Close the progress bar
progress_callback(1.0)
progress_bar.close() progress_bar.close()
logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}") logger.info(f"Successfully downloaded {os.path.basename(temp_downloaded_file_path)}")
else: else:
logger.error(f"Failed to download the file. Status code: {response.status_code}") logger.error(f"Failed to download the file. Status code: {response.status_code}")
return return None
os.makedirs(download_location, exist_ok=True) os.makedirs(download_location, exist_ok=True)

View File

@@ -23,6 +23,14 @@ class EngineManager:
def supported_engines(): def supported_engines():
return [Blender, FFMPEG] 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 @classmethod
def engine_with_name(cls, engine_name): def engine_with_name(cls, engine_name):
for obj in cls.supported_engines(): for obj in cls.supported_engines():
@@ -30,7 +38,15 @@ class EngineManager:
return obj return obj
@classmethod @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: if not cls.engines_path:
raise FileNotFoundError("Engine path is not set") raise FileNotFoundError("Engine path is not set")
@@ -63,13 +79,13 @@ class EngineManager:
) )
result_dict['path'] = path result_dict['path'] = path
# fetch version number from binary - helps detect corrupted downloads # fetch version number from binary - helps detect corrupted downloads - disabled due to perf issues
binary_version = eng(path).version() # binary_version = eng(path).version()
if not binary_version: # if not binary_version:
logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}") # logger.warning(f"Possible corrupt {eng.name()} {result_dict['version']} install detected: {path}")
if not include_corrupt: # if not include_corrupt:
continue # continue
result_dict['version'] = binary_version or 'error' # 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 # 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']: if not filter_name or filter_name == result_dict['engine']:
@@ -92,6 +108,7 @@ class EngineManager:
'type': 'system' 'type': 'system'
} }
if not ignore_system:
with concurrent.futures.ThreadPoolExecutor() as executor: with concurrent.futures.ThreadPoolExecutor() as executor:
futures = { futures = {
executor.submit(fetch_engine_details, eng, include_corrupt): eng.name() executor.submit(fetch_engine_details, eng, include_corrupt): eng.name()
@@ -107,31 +124,31 @@ class EngineManager:
return results return results
@classmethod @classmethod
def all_versions_for_engine(cls, engine_name, include_corrupt=False): 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) 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) sorted_versions = sorted(versions, key=lambda x: x['version'], reverse=True)
return sorted_versions return sorted_versions
@classmethod @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() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() cpu = cpu or current_system_cpu()
try: try:
filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and filtered = [x for x in cls.all_versions_for_engine(engine, ignore_system=ignore_system)
x['cpu'] == cpu] if x['system_os'] == system_os and x['cpu'] == cpu]
return filtered[0] return filtered[0]
except IndexError: except IndexError:
logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}") logger.error(f"Cannot find newest engine version for {engine}-{system_os}-{cpu}")
return None return None
@classmethod @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() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() cpu = cpu or current_system_cpu()
filtered = [x for x in cls.get_engines(filter_name=engine) if x['system_os'] == system_os and filtered = [x for x in cls.get_engines(filter_name=engine, ignore_system=ignore_system) if
x['cpu'] == cpu and x['version'] == version] x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version]
return filtered[0] if filtered else False return filtered[0] if filtered else False
@classmethod @classmethod
@@ -164,7 +181,7 @@ class EngineManager:
return None return None
@classmethod @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) engine_to_download = cls.engine_with_name(engine)
existing_task = cls.get_existing_download_task(engine, version, system_os, cpu) 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}") logger.debug(f"Already downloading {engine} {version}")
if not background: if not background:
existing_task.join() # If download task exists, wait until it's done downloading existing_task.join() # If download task exists, wait until it's done downloading
return return None
elif not engine_to_download.downloader(): elif not engine_to_download.downloader():
logger.warning("No valid downloader for this engine. Please update this software manually.") logger.warning("No valid downloader for this engine. Please update this software manually.")
return return None
elif not cls.engines_path: elif not cls.engines_path:
raise FileNotFoundError("Engines path must be set before requesting downloads") raise FileNotFoundError("Engines path must be set before requesting downloads")
@@ -187,7 +204,7 @@ class EngineManager:
return thread return thread
thread.join() 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: if not found_engine:
logger.error(f"Error downloading {engine}") logger.error(f"Error downloading {engine}")
return found_engine return found_engine
@@ -213,31 +230,21 @@ class EngineManager:
return False return False
@classmethod @classmethod
def update_all_engines(cls): def is_engine_update_available(cls, engine_class, ignore_system_installs=False):
def engine_update_task(engine_class):
logger.debug(f"Checking for updates to {engine_class.name()}") logger.debug(f"Checking for updates to {engine_class.name()}")
latest_version = engine_class.downloader().find_most_recent_version() latest_version = engine_class.downloader().find_most_recent_version()
if not latest_version: if not latest_version:
logger.warning(f"Could not find most recent version of {engine.name()} to download") logger.warning(f"Could not find most recent version of {engine_class.name()} to download")
return return None
version_num = latest_version.get('version') version_num = latest_version.get('version')
if cls.is_version_downloaded(engine_class.name(), version_num): 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") logger.debug(f"Latest version of {engine_class.name()} ({version_num}) already downloaded")
return return None
# download the engine return latest_version
logger.info(f"Downloading latest version of {engine_class.name()} ({version_num})...")
cls.download_engine(engine=engine_class.name(), version=version_num, background=True)
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 @classmethod
def create_worker(cls, engine_name, input_path, output_path, engine_version=None, args=None, parent=None, name=None): 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.version = version
self.system_os = system_os self.system_os = system_os
self.cpu = cpu self.cpu = cpu
self.percent_complete = 0
def _update_progress(self, current_progress):
self.percent_complete = current_progress
def run(self): def run(self):
try: 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: if existing_download:
logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists") logger.info(f"Requested download of {self.engine} {self.version}, but local copy already exists")
return existing_download return existing_download
# Get the appropriate downloader class based on the engine type # Get the appropriate downloader class based on the engine type
EngineManager.engine_with_name(self.engine).downloader().download_engine( downloader = EngineManager.engine_with_name(self.engine).downloader()
self.version, download_location=EngineManager.engines_path, system_os=self.system_os, cpu=self.cpu, downloader.download_engine( self.version, download_location=EngineManager.engines_path,
timeout=300) system_os=self.system_os, cpu=self.cpu, timeout=300, progress_callback=self._update_progress)
except Exception as e: except Exception as e:
logger.error(f"Error in download worker: {e}") logger.error(f"Error in download worker: {e}")
finally: finally:

View File

@@ -97,7 +97,7 @@ class FFMPEGDownloader(EngineDownloader):
'windows': cls.__get_windows_versions} 'windows': cls.__get_windows_versions}
if not versions_per_os.get(system_os): if not versions_per_os.get(system_os):
logger.error(f"Cannot find version list for {system_os}") logger.error(f"Cannot find version list for {system_os}")
return return None
results = [] results = []
all_versions = versions_per_os[system_os]() all_versions = versions_per_os[system_os]()
@@ -144,7 +144,7 @@ class FFMPEGDownloader(EngineDownloader):
return None return None
@classmethod @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() system_os = system_os or current_system_os()
cpu = cpu or current_system_cpu() 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] found_version = [item for item in cls.all_versions(system_os, cpu) if item['version'] == version]
if not found_version: if not found_version:
logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}") logger.error(f"Cannot find FFMPEG version {version} for {system_os} and {cpu}")
return return None
# Platform specific naming cleanup # Platform specific naming cleanup
remote_url = cls.__get_remote_url_for_version(version=version, system_os=system_os, cpu=cpu) 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 # Download and extract
try: try:
logger.info(f"Requesting download of ffmpeg-{version}-{system_os}-{cpu}") 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 # naming cleanup to match existing naming convention
output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}') output_path = os.path.join(download_location, f'ffmpeg-{version}-{system_os}-{cpu}')

View File

@@ -128,18 +128,18 @@ class EngineBrowserWindow(QMainWindow):
self.launch_button.setEnabled(is_localhost(self.hostname)) self.launch_button.setEnabled(is_localhost(self.hostname))
def update_download_status(self): 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) hide_progress = not bool(running_tasks)
self.progress_bar.setHidden(hide_progress) self.progress_bar.setHidden(hide_progress)
self.progress_label.setHidden(hide_progress) self.progress_label.setHidden(hide_progress)
# Update the status labels # Update the status labels
if len(EngineManager.download_tasks) == 0: if len(running_tasks) == 0:
new_status = "" new_status = ""
elif len(EngineManager.download_tasks) == 1: elif len(running_tasks) == 1:
task = EngineManager.download_tasks[0] task = running_tasks[0]
new_status = f"Downloading {task.engine.capitalize()} {task.version}..." new_status = f"Downloading {task.engine.capitalize()} {task.version}..."
else: else:
new_status = f"Downloading {len(EngineManager.download_tasks)} engines..." new_status = f"Downloading {len(running_tasks)} engines..."
self.progress_label.setText(new_status) self.progress_label.setText(new_status)
def launch_button_click(self): def launch_button_click(self):

View File

@@ -91,12 +91,13 @@ class MainWindow(QMainWindow):
self.setup_ui(main_layout) self.setup_ui(main_layout)
self.create_toolbars()
# Add Widgets to Window # Add Widgets to Window
# self.custom_menu_bar =
self.setMenuBar(MenuBar(self)) self.setMenuBar(MenuBar(self))
self.setStatusBar(StatusBar(self)) self.setStatusBar(StatusBar(self))
self.create_toolbars()
# start background update # start background update
self.bg_update_thread = QThread() self.bg_update_thread = QThread()
self.bg_update_thread.run = self.__background_update self.bg_update_thread.run = self.__background_update
@@ -504,9 +505,9 @@ class MainWindow(QMainWindow):
# Top Toolbar Buttons # Top Toolbar Buttons
self.topbar.add_button( 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( 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_separator()
self.topbar.add_button( self.topbar.add_button(
"Stop Job", f"{resources_directory}/StopSign.png", self.stop_job) "Stop Job", f"{resources_directory}/StopSign.png", self.stop_job)

554
src/ui/settings_window.py Normal file
View File

@@ -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()

View File

@@ -14,6 +14,8 @@ class MenuBar(QMenuBar):
def __init__(self, parent=None) -> None: def __init__(self, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self.settings_window = None
# setup menus # setup menus
file_menu = self.addMenu("File") file_menu = self.addMenu("File")
# edit_menu = self.addMenu("Edit") # edit_menu = self.addMenu("Edit")
@@ -30,7 +32,7 @@ class MenuBar(QMenuBar):
settings_action = QAction("Settings...", self) settings_action = QAction("Settings...", self)
settings_action.triggered.connect(self.show_settings) settings_action.triggered.connect(self.show_settings)
settings_action.setShortcut(f'Ctrl+,') 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
exit_action = QAction('&Exit', self) exit_action = QAction('&Exit', self)
exit_action.setShortcut('Ctrl+Q') exit_action.setShortcut('Ctrl+Q')
@@ -49,7 +51,9 @@ class MenuBar(QMenuBar):
self.parent().new_job() self.parent().new_job()
def show_settings(self): def show_settings(self):
pass from src.ui.settings_window import SettingsWindow
self.settings_window = SettingsWindow()
self.settings_window.show()
@staticmethod @staticmethod
def show_about(): def show_about():

View File

@@ -35,12 +35,13 @@ class StatusBar(QStatusBar):
try: try:
# update status label - get download status # update status label - get download status
new_status = proxy.status() new_status = proxy.status()
if EngineManager.download_tasks: active_downloads = EngineManager.active_downloads()
if len(EngineManager.download_tasks) == 1: if active_downloads:
task = EngineManager.download_tasks[0] if len(active_downloads) == 1:
task = active_downloads[0]
new_status = f"{new_status} | Downloading {task.engine.capitalize()} {task.version}..." new_status = f"{new_status} | Downloading {task.engine.capitalize()} {task.version}..."
else: 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) self.messageLabel.setText(new_status)
# update status image # update status image

View File

@@ -7,6 +7,7 @@ import shutil
import socket import socket
import string import string
import subprocess import subprocess
import sys
from datetime import datetime from datetime import datetime
logger = logging.getLogger() logger = logging.getLogger()
@@ -139,6 +140,40 @@ def current_system_cpu():
# convert all x86 64 to "x64" # convert all x86 64 to "x64"
return platform.machine().lower().replace('amd64', 'x64').replace('x86_64', '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(): def resources_dir():
resource_environment_path = os.environ.get('RESOURCEPATH', None) resource_environment_path = os.environ.get('RESOURCEPATH', None)
@@ -284,7 +319,11 @@ def get_gpu_info():
def get_macos_gpu_info(): def get_macos_gpu_info():
"""Get GPU info on macOS (works with Apple Silicon)""" """Get GPU info on macOS (works with Apple Silicon)"""
try: 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) capture_output=True, text=True, timeout=5)
data = json.loads(result.stdout) data = json.loads(result.stdout)
@@ -296,7 +335,7 @@ def get_gpu_info():
'name': display.get('sppci_model', 'Unknown GPU'), 'name': display.get('sppci_model', 'Unknown GPU'),
'memory': display.get('sppci_vram', 'Integrated'), '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: except Exception as e:
print(f"Failed to get macOS GPU info: {e}") print(f"Failed to get macOS GPU info: {e}")
return [{'name': 'Unknown GPU', 'memory': 'Unknown'}] return [{'name': 'Unknown GPU', 'memory': 'Unknown'}]