11 Commits

Author SHA1 Message Date
1aa1d55954 Mac try again 2024-08-23 21:33:41 -05:00
1b2f005ab9 Try updated main.spec for macOS 2024-08-23 21:19:07 -05:00
78131df096 First ARM macOS test 2024-08-23 21:02:35 -05:00
69e377835e Fix generated executable name on Windows 2024-08-23 20:37:30 -05:00
14bbff48da Try to label generated executables with version number 2024-08-23 20:31:31 -05:00
75cd12803f Try to rename executables 2024-08-23 20:18:29 -05:00
ad5d132589 try again with all of them 2024-08-23 20:08:57 -05:00
5d124ae378 trying again 2024-08-23 20:07:17 -05:00
21ad9c7e7e Update pyinstaller.yml 2024-08-23 20:03:15 -05:00
9fb7fae567 Update pyinstaller.yml 2024-08-23 20:00:49 -05:00
a50dc3ab32 Rename create-executables.yml to pyinstaller.yml 2024-08-23 19:59:22 -05:00
10 changed files with 125 additions and 686 deletions

View File

@@ -3,36 +3,65 @@ name: Create Executables
on: on:
workflow_dispatch: workflow_dispatch:
release: release:
- types: [created] types:
- created
push:
branches:
- github-actions
jobs: jobs:
pyinstaller-build-windows: # pyinstaller-build-windows:
runs-on: windows-latest # runs-on: windows-latest
steps: # steps:
- name: Create Executables (Windows) # - name: Checkout code
uses: sayyid5416/pyinstaller@v1 # uses: actions/checkout@v4
with: # - name: Extract Version
python_ver: '3.11' # id: get_version
spec: 'main.spec' # run: |
requirements: 'requirements.txt' # $version = python -c "from version import APP_VERSION; print(APP_VERSION)"
upload_exe_with_name: 'Zordon' # echo "VERSION=$version" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8
pyinstaller-build-linux: # - name: Create Executable (Windows-x64)
runs-on: ubuntu-latest # uses: sayyid5416/pyinstaller@v1
steps: # with:
- name: Create Executables (Linux) # python_ver: '3.11'
uses: sayyid5416/pyinstaller@v1 # python_arch: 'x64'
with: # spec: 'main.spec'
python_ver: '3.11' # requirements: 'requirements.txt'
spec: 'main.spec' # upload_exe_with_name: 'Zordon-${{env.VERSION}}-Windows-x64'
requirements: 'requirements.txt' # pyinstaller-build-linux:
upload_exe_with_name: 'Zordon' # runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
# - name: Extract Version
# id: get_version
# run: |
# version=$(python -c 'from version import APP_VERSION; print(APP_VERSION)')
# echo "VERSION=$version" >> $GITHUB_ENV
# - name: Create Executable (Linux-x64)
# uses: sayyid5416/pyinstaller@v1
# with:
# python_ver: '3.11'
# python_arch: 'x64'
# spec: 'main.spec'
# requirements: 'requirements.txt'
# upload_exe_with_name: 'Zordon-${{env.VERSION}}-Linux-x64'
pyinstaller-build-macos: pyinstaller-build-macos:
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Create Executables (macOS) # - name: Checkout code
# uses: actions/checkout@v4
# - name: Extract Version
# id: get_version
# run: |
# version=$(python -c 'from version import APP_VERSION; print(APP_VERSION)')
# echo "VERSION=$version" >> $GITHUB_ENV
- name: Create Executable (macOS-arm64)
uses: sayyid5416/pyinstaller@v1 uses: sayyid5416/pyinstaller@v1
with: with:
python_ver: '3.11' python_ver: '3.11'
python_arch: 'arm64'
spec: 'main.spec' spec: 'main.spec'
requirements: 'requirements.txt' requirements: 'requirements.txt'
upload_exe_with_name: 'Zordon' upload_exe_with_name: 'Zordon-testing-macOS-arm64'
options: --onefile

View File

@@ -26,7 +26,7 @@ a = Analysis(
runtime_hooks=[], runtime_hooks=[],
excludes=[], excludes=[],
noarchive=False, noarchive=False,
optimize=1, # fyi: optim level 2 breaks on windows optimize=0,
) )
pyz = PYZ(a.pure) pyz = PYZ(a.pure)
@@ -40,7 +40,7 @@ if platform.system() == 'Darwin': # macOS
name=APP_NAME, name=APP_NAME,
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=True, strip=False,
upx=True, upx=True,
console=False, console=False,
disable_windowed_traceback=False, disable_windowed_traceback=False,
@@ -49,10 +49,17 @@ if platform.system() == 'Darwin': # macOS
codesign_identity=None, codesign_identity=None,
entitlements_file=None, entitlements_file=None,
) )
app = BUNDLE( coll = COLLECT(
exe, exe,
a.binaries, a.binaries,
a.datas, a.datas,
strip=False,
upx=True,
upx_exclude=[],
name='server',
)
app = BUNDLE(
coll,
strip=True, strip=True,
name=f'{APP_NAME}.app', name=f'{APP_NAME}.app',
icon=None, icon=None,
@@ -87,7 +94,7 @@ elif platform.system() == 'Windows':
name=APP_NAME, name=APP_NAME,
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=True, strip=False,
upx=True, upx=True,
console=False, console=False,
disable_windowed_traceback=False, disable_windowed_traceback=False,
@@ -108,7 +115,7 @@ else: # linux
name=APP_NAME, name=APP_NAME,
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=True, strip=False,
upx=True, upx=True,
console=False, console=False,
disable_windowed_traceback=False, disable_windowed_traceback=False,

View File

@@ -50,11 +50,8 @@ def start_server(hostname=None):
flask_log.setLevel(Config.flask_log_level.upper()) flask_log.setLevel(Config.flask_log_level.upper())
logger.debug('Starting API server') logger.debug('Starting API server')
try: server.run(host='0.0.0.0', port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False,
server.run(host=hostname, port=server.config['PORT'], debug=Config.flask_debug_enable, use_reloader=False, threaded=True)
threaded=True)
finally:
logger.debug('Stopping API server')
# -------------------------------------------- # --------------------------------------------

View File

@@ -8,7 +8,8 @@ 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']

View File

@@ -23,10 +23,6 @@ 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 @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():
@@ -34,7 +30,7 @@ class EngineManager:
return obj return obj
@classmethod @classmethod
def get_engines(cls, filter_name=None, include_corrupt=False, ignore_system=False): def get_engines(cls, filter_name=None, include_corrupt=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")
@@ -96,47 +92,46 @@ 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() for eng in cls.supported_engines()
for eng in cls.supported_engines() if eng.default_renderer_path() and (not filter_name or filter_name == eng.name())
if eng.default_renderer_path() and (not filter_name or filter_name == eng.name()) }
}
for future in concurrent.futures.as_completed(futures): for future in concurrent.futures.as_completed(futures):
result = future.result() result = future.result()
if result: if result:
results.append(result) results.append(result)
return results return results
@classmethod @classmethod
def all_versions_for_engine(cls, engine_name, include_corrupt=False, ignore_system=False): def all_versions_for_engine(cls, engine_name, include_corrupt=False):
versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt, ignore_system=ignore_system) versions = cls.get_engines(filter_name=engine_name, include_corrupt=include_corrupt)
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, ignore_system=None): def newest_engine_version(cls, engine, system_os=None, cpu=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, ignore_system=ignore_system) filtered = [x for x in cls.all_versions_for_engine(engine) if x['system_os'] == system_os and
if x['system_os'] == system_os and x['cpu'] == cpu] 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, ignore_system=False): def is_version_downloaded(cls, engine, version, system_os=None, cpu=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()
filtered = [x for x in cls.get_engines(filter_name=engine, ignore_system=ignore_system) if filtered = [x for x in cls.get_engines(filter_name=engine) if x['system_os'] == system_os and
x['system_os'] == system_os and x['cpu'] == cpu and x['version'] == version] x['cpu'] == cpu and x['version'] == version]
return filtered[0] if filtered else False return filtered[0] if filtered else False
@classmethod @classmethod
@@ -169,7 +164,7 @@ class EngineManager:
return None return None
@classmethod @classmethod
def download_engine(cls, engine, version, system_os=None, cpu=None, background=False, ignore_system=False): def download_engine(cls, engine, version, system_os=None, cpu=None, background=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)
@@ -192,7 +187,7 @@ class EngineManager:
return thread return thread
thread.join() thread.join()
found_engine = cls.is_version_downloaded(engine, version, system_os, cpu, ignore_system) # Check that engine downloaded found_engine = cls.is_version_downloaded(engine, version, system_os, cpu) # 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
@@ -218,21 +213,31 @@ class EngineManager:
return False return False
@classmethod @classmethod
def is_engine_update_available(cls, engine_class, ignore_system_installs=False): def update_all_engines(cls):
logger.debug(f"Checking for updates to {engine_class.name()}") def engine_update_task(engine_class):
latest_version = engine_class.downloader().find_most_recent_version() logger.debug(f"Checking for updates to {engine_class.name()}")
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_class.name()} to download") logger.warning(f"Could not find most recent version of {engine.name()} to download")
return return
version_num = latest_version.get('version') version_num = latest_version.get('version')
if cls.is_version_downloaded(engine_class.name(), version_num, ignore_system=ignore_system_installs): if cls.is_version_downloaded(engine_class.name(), version_num):
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
return latest_version # 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)
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, renderer, input_path, output_path, engine_version=None, args=None, parent=None, name=None): def create_worker(cls, renderer, input_path, output_path, engine_version=None, args=None, parent=None, name=None):
@@ -300,8 +305,7 @@ class EngineDownloadWorker(threading.Thread):
self.cpu = cpu self.cpu = cpu
def run(self): def run(self):
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

View File

@@ -5,9 +5,6 @@ import socket
import sys import sys
import threading import threading
from collections import deque from collections import deque
from datetime import datetime
from PyQt6.QtCore import QSettings
from src.api.api_server import start_server from src.api.api_server import start_server
from src.api.preview_manager import PreviewManager from src.api.preview_manager import PreviewManager
@@ -16,10 +13,8 @@ from src.distributed_job_manager import DistributedJobManager
from src.engines.engine_manager import EngineManager 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 (system_safe_path, current_system_cpu, current_system_os, from src.utilities.misc_helper import system_safe_path, current_system_cpu, current_system_os, current_system_os_version
current_system_os_version, check_for_updates)
from src.utilities.zeroconf_server import ZeroconfServer from src.utilities.zeroconf_server import ZeroconfServer
from version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER, APP_AUTHOR
logger = logging.getLogger() logger = logging.getLogger()
@@ -34,45 +29,16 @@ def run(server_only=False) -> int:
int: The exit status code. int: The exit status code.
""" """
def existing_process(process_name):
import psutil
current_pid = os.getpid()
current_process = psutil.Process(current_pid)
for proc in psutil.process_iter(['pid', 'name', 'ppid']):
proc_name = proc.info['name'].lower().rstrip('.exe')
if proc_name == process_name.lower() and proc.info['pid'] != current_pid:
if proc.info['pid'] == current_process.ppid():
continue # parent process
elif proc.info['ppid'] == current_pid:
continue # child process
else:
return proc # unrelated process
return None
# setup logging # setup logging
logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S', logging.basicConfig(format='%(asctime)s: %(levelname)s: %(module)s: %(message)s', datefmt='%d-%b-%y %H:%M:%S',
level=Config.server_log_level.upper()) level=Config.server_log_level.upper())
logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging logging.getLogger("requests").setLevel(logging.WARNING) # suppress noisy requests/urllib3 logging
logging.getLogger("urllib3").setLevel(logging.WARNING) logging.getLogger("urllib3").setLevel(logging.WARNING)
# check for existing instance
existing_proc = existing_process(APP_NAME)
if existing_proc:
logger.fatal(f"Another instance of {APP_NAME} is already running (pid: {existing_proc.pid})")
sys.exit(1)
# Setup logging for console ui # Setup logging for console ui
buffer_handler = __setup_buffer_handler() if not server_only else None buffer_handler = __setup_buffer_handler() if not server_only else None
# check for updates logger.info(f"Starting Zordon Render Server")
update_thread = threading.Thread(target=check_for_updates, args=(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME,
APP_VERSION))
update_thread.start()
settings = QSettings(APP_AUTHOR, APP_NAME)
# main start
logger.info(f"Starting {APP_NAME} Render Server")
return_code = 0 return_code = 0
try: try:
# Load Config YAML # Load Config YAML
@@ -97,16 +63,9 @@ def run(server_only=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 # check for updates for render engines if configured or on first launch
ignore_system = settings.value("engines_ignore_system_installs", False) if Config.update_engines_on_launch or not EngineManager.get_engines():
if settings.value('check_for_engine_updates_on_launch', False): EngineManager.update_all_engines()
for engine in EngineManager.downloadable_engines():
if settings.value(f'engine_download-{engine.name()}', False):
update_result = EngineManager.is_engine_update_available(engine, ignore_system_installs=ignore_system)
EngineManager.download_engine(engine=engine.name(), version=update_result['version'],
background=True,
ignore_system=ignore_system)
settings.setValue("engines_last_update_time", datetime.now().isoformat())
# get hostname # get hostname
local_hostname = socket.gethostname() local_hostname = socket.gethostname()
@@ -118,13 +77,13 @@ def run(server_only=False) -> int:
api_server.start() api_server.start()
# start zeroconf server # start zeroconf server
ZeroconfServer.configure(f"_{APP_NAME.lower()}._tcp.local.", local_hostname, Config.port_number) ZeroconfServer.configure("_zordon._tcp.local.", local_hostname, Config.port_number)
ZeroconfServer.properties = {'system_cpu': current_system_cpu(), ZeroconfServer.properties = {'system_cpu': current_system_cpu(),
'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()}
ZeroconfServer.start() ZeroconfServer.start()
logger.info(f"{APP_NAME} Render Server started - Hostname: {local_hostname}") logger.info(f"Zordon Render Server started - Hostname: {local_hostname}")
RenderQueue.start() # Start evaluating the render queue RenderQueue.start() # Start evaluating the render queue
# start in gui or server only (cli) mode # start in gui or server only (cli) mode
@@ -141,13 +100,13 @@ def run(server_only=False) -> int:
return_code = 1 return_code = 1
finally: finally:
# shut down gracefully # shut down gracefully
logger.info(f"{APP_NAME} Render Server is preparing to shut down") logger.info(f"Zordon Render Server is preparing to shut down")
try: try:
RenderQueue.prepare_for_shutdown() RenderQueue.prepare_for_shutdown()
except Exception as e: except Exception as e:
logger.exception(f"Exception during prepare for shutdown: {e}") logger.exception(f"Exception during prepare for shutdown: {e}")
ZeroconfServer.stop() ZeroconfServer.stop()
logger.info(f"{APP_NAME} Render Server has shut down") logger.info(f"Zordon Render Server has shut down")
return sys.exit(return_code) return sys.exit(return_code)

View File

@@ -1,481 +0,0 @@
import os
from pathlib import Path
import humanize
import socket
from datetime import datetime
from PyQt6 import QtCore
from PyQt6.QtCore import Qt, QSettings, pyqtSignal as Signal
from PyQt6.QtGui import QIcon
from PyQt6.QtWidgets import QApplication, QMainWindow, QListWidget, QListWidgetItem, QStackedWidget, QVBoxLayout, \
QWidget, QLabel, QCheckBox, QLineEdit, \
QComboBox, QPushButton, QHBoxLayout, QGroupBox, QTableWidget, QAbstractItemView, QTableWidgetItem, QHeaderView, \
QMessageBox
from api.server_proxy import RenderServerProxy
from engines.engine_manager import EngineManager
from utilities.config import Config
from utilities.misc_helper import launch_url, system_safe_path
from version import APP_AUTHOR, APP_NAME
settings = QSettings(APP_AUTHOR, APP_NAME)
class SettingsWindow(QMainWindow):
def __init__(self):
super().__init__()
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)
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))
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 = os.path.expanduser(Config.upload_folder)
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 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"))
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"))
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"))
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))
enable_sharing_checkbox.stateChanged.connect(self.toggle_render_sharing)
sharing_layout.addWidget(enable_sharing_checkbox)
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))
self.enable_network_password_checkbox.stateChanged.connect(self.enable_network_password_changed)
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(settings.value("enable_network_password", False))
password_layout.addWidget(self.network_password_line)
self.show_password_button = QPushButton("Show")
self.show_password_button.setEnabled(settings.value("enable_network_password", False))
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)
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.Normal)
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))
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)
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)
check_for_engine_updates_checkbox = QCheckBox("Check for new versions on launch")
check_for_engine_updates_checkbox.setChecked(settings.value('check_for_engine_updates_on_launch', True))
check_for_engine_updates_checkbox.setEnabled(at_least_one_downloadable)
check_for_engine_updates_checkbox.stateChanged.connect(
lambda state: settings.setValue("check_for_engine_updates_on_launch", bool(state)))
engine_updates_layout.addWidget(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.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_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)
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'))
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)
self.installed_engines_table.update_table(use_cached=False)
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)
messagebox_shown = False
for engine in EngineManager.downloadable_engines():
if settings.value(f'engine_download-{engine.name()}', False):
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.update_engine_download_status()
if not messagebox_shown:
msg_box = QMessageBox()
msg_box.setWindowTitle("No Updates Available")
msg_box.setText("All your render engines are up-to-date.")
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 = [x for x in EngineManager.download_tasks if x.is_alive()]
if not running_tasks:
self.update_last_checked_label()
return
self.engines_last_update_label.setText(f"Downloading {running_tasks[0].engine} ({running_tasks[0].version})...")
class EngineTableWidget(QWidget):
row_selected = Signal()
def __init__(self):
super().__init__()
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_table()
super().showEvent(event) # Ensure normal event processing
def update_table(self, use_cached=True):
if not self.raw_server_data or not use_cached:
self.raw_server_data = RenderServerProxy(socket.gethostname()).get_renderer_info()
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):
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

@@ -1,6 +1,8 @@
''' app/ui/widgets/menubar.py ''' ''' app/ui/widgets/menubar.py '''
import sys
from PyQt6.QtGui import QAction from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QMenuBar, QApplication, QMessageBox, QDialog, QVBoxLayout, QLabel, QPushButton from PyQt6.QtWidgets import QMenuBar, QApplication
class MenuBar(QMenuBar): class MenuBar(QMenuBar):
@@ -14,8 +16,6 @@ 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")
@@ -32,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) # file_menu.addAction(settings_action) # todo: enable once we have a setting screen
# exit # exit
exit_action = QAction('&Exit', self) exit_action = QAction('&Exit', self)
exit_action.setShortcut('Ctrl+Q') exit_action.setShortcut('Ctrl+Q')
@@ -43,63 +43,15 @@ class MenuBar(QMenuBar):
about_action = QAction("About", self) about_action = QAction("About", self)
about_action.triggered.connect(self.show_about) about_action.triggered.connect(self.show_about)
help_menu.addAction(about_action) help_menu.addAction(about_action)
update_action = QAction("Check for Updates...", self)
update_action.triggered.connect(self.check_for_updates)
help_menu.addAction(update_action)
def new_job(self): def new_job(self):
self.parent().new_job() self.parent().new_job()
def show_settings(self): def show_settings(self):
from src.ui.settings_window import SettingsWindow pass
self.settings_window = SettingsWindow()
self.settings_window.show()
@staticmethod @staticmethod
def show_about(): def show_about():
from src.ui.about_window import AboutDialog from src.ui.about_window import AboutDialog
dialog = AboutDialog() dialog = AboutDialog()
dialog.exec() dialog.exec()
@staticmethod
def check_for_updates():
from src.utilities.misc_helper import check_for_updates
from version import APP_NAME, APP_VERSION, APP_REPO_NAME, APP_REPO_OWNER
found_update = check_for_updates(APP_REPO_NAME, APP_REPO_OWNER, APP_NAME, APP_VERSION)
if found_update:
dialog = UpdateDialog(found_update, APP_VERSION)
dialog.exec()
else:
QMessageBox.information(None, "No Update", "No updates available.")
class UpdateDialog(QDialog):
def __init__(self, release_info, current_version, parent=None):
super().__init__(parent)
self.setWindowTitle(f"Update Available ({current_version} -> {release_info['tag_name']})")
layout = QVBoxLayout()
label = QLabel(f"A new version ({release_info['tag_name']}) is available! Current version: {current_version}")
layout.addWidget(label)
# Label to show the release notes
description = QLabel(release_info["body"])
layout.addWidget(description)
# Button to download the latest version
download_button = QPushButton(f"Download Latest Version ({release_info['tag_name']})")
download_button.clicked.connect(lambda: self.open_url(release_info["html_url"]))
layout.addWidget(download_button)
# OK button to dismiss the dialog
ok_button = QPushButton("Dismiss")
ok_button.clicked.connect(self.accept) # Close the dialog when clicked
layout.addWidget(ok_button)
self.setLayout(layout)
def open_url(self, url):
from PyQt6.QtCore import QUrl
from PyQt6.QtGui import QDesktopServices
QDesktopServices.openUrl(QUrl(url))
self.accept()

View File

@@ -159,33 +159,6 @@ def copy_directory_contents(src_dir, dst_dir):
shutil.copy2(src_path, dst_path) shutil.copy2(src_path, dst_path)
def check_for_updates(repo_name, repo_owner, app_name, current_version):
def get_github_releases(owner, repo):
import requests
url = f"https://api.github.com/repos/{owner}/{repo}/releases"
try:
response = requests.get(url, timeout=3)
response.raise_for_status()
releases = response.json()
return releases
except Exception as e:
logger.error(f"Error checking for updates: {e}")
return []
releases = get_github_releases(repo_owner, repo_name)
if not releases:
return
latest_version = releases[0]
latest_version_tag = latest_version['tag_name']
from packaging import version
if version.parse(latest_version_tag) > version.parse(current_version):
logger.info(f"Newer version of {app_name} available. "
f"Latest: {latest_version_tag}, Current: {current_version}")
return latest_version
def is_localhost(comparison_hostname): def is_localhost(comparison_hostname):
# this is necessary because socket.gethostname() does not always include '.local' - This is a sanitized comparison # this is necessary because socket.gethostname() does not always include '.local' - This is a sanitized comparison
try: try:

View File

@@ -4,5 +4,3 @@ APP_AUTHOR = "Brett Williams"
APP_DESCRIPTION = "Distributed Render Farm Tools" APP_DESCRIPTION = "Distributed Render Farm Tools"
APP_COPYRIGHT_YEAR = "2024" APP_COPYRIGHT_YEAR = "2024"
APP_LICENSE = "MIT License" APP_LICENSE = "MIT License"
APP_REPO_NAME = APP_NAME
APP_REPO_OWNER = "blw1138"