Compare commits

...

6 Commits

Author SHA1 Message Date
Vxrtrauter c4685dc2de fix: retry file deletion to allow libtorrent to release handles 2026-03-11 00:57:48 +01:00
Vxrtrauter f34d02d3d0 fix: minor inexistent variable in loghandler 2026-03-11 00:44:53 +01:00
Vxrtrauter 91b5c12afa feat: multithreaded update downloads + refactor updater into utils/network 2026-03-11 00:43:00 +01:00
Vxrtrauter b77867b111 feat: QoL features to Updater, workflow trigger. 2026-03-11 00:07:11 +01:00
Vxrtrauter bf20625832 Fix Torrent Handle errors 2026-03-10 22:45:25 +01:00
Vxrtrauter 8d218443bf fix: wrong download paths in libtorrent logic 2026-03-10 22:35:49 +01:00
6 changed files with 226 additions and 148 deletions
+123 -139
View File
@@ -1,16 +1,17 @@
from core.utils.logging.logs import consoleLog, remove_download_log, flush_log_buffer
from core.utils.general.wrappers import run_thread
from core.utils.data.state import state
from core.interface.assets.base64_icons import settings_white_base64
from core.interface.assets.base64_icons import settings_black_base64
from core.interface.utils.searchhelper import return_pressed
from core.interface.dialogs.settings import settings_dialog
from core.interface.assets.base64_icons import logo_base64
from core.utils.network.download import download_selected
from core.utils.network.update_checker import get_updates
from core.utils.network.updater import download_update
from core.interface.utils.tabhelper import create_tab
from core.interface.utils.searchhelper import return_pressed
from core.interface.utils.svghelper import svg_icon
from core.interface.dialogs.settings import settings_dialog
from core.interface.assets.base64_icons import settings_black_base64
from core.interface.assets.base64_icons import settings_white_base64
from core.interface.assets.base64_icons import logo_base64
from core.utils.general.shutdown import closehelper
from core.utils.general.wrappers import run_thread
from core.utils.data.state import state
from PySide6 import QtWidgets
from PySide6.QtCore import (
@@ -52,16 +53,16 @@ from PySide6.QtGui import (
QColor,
)
import libtorrent as lt
import subprocess
import darkdetect
import threading
import platform
import requests as r
import os
import subprocess
import libtorrent as lt
import sys
import json
import base64
import json
import time
import sys
import os
def _is_dark_mode():
@@ -141,75 +142,9 @@ SVG_PLAY = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><polygon
SVG_PAUSE = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="5" y="3" width="4" height="18" rx="1" fill="{color}"/><rect x="15" y="3" width="4" height="18" rx="1" fill="{color}"/></svg>'
SVG_FOLDER = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M2 6c0-1.1.9-2 2-2h5l2 2h7c1.1 0 2 .9 2 2v10c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6z" fill="{color}"/></svg>'
def _verify_hash(file_path, expected_hash):
import hashlib
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}" == expected_hash
def _download_update(assets):
import tempfile
import time
for asset in assets:
if "-windows-setup.exe" in asset["name"]:
filename = asset["name"]
setup_hash = asset["hash"]
url = asset["url"]
break
if not filename:
consoleLog("Error: No Windows installer found in release assets")
return
installer_path = os.path.join(tempfile.gettempdir(), filename)
progress = QtWidgets.QProgressDialog("Downloading installer...", None, 0, 0)
progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setRange(0, 100)
progress.setValue(0)
progress.show()
QtWidgets.QApplication.processEvents()
response = r.get(url, allow_redirects=True, stream=True)
total = int(response.headers.get("content-length", 0))
downloaded = 0
with open(installer_path, "wb") as f:
for chunk in response.iter_content(chunk_size=65536):
f.write(chunk)
downloaded += len(chunk)
if total > 0:
progress.setValue(int(downloaded * 100 / total))
QtWidgets.QApplication.processEvents()
if not os.path.exists(installer_path):
progress.close()
raise FileNotFoundError("Executable not found")
if setup_hash:
if _verify_hash(installer_path, setup_hash):
consoleLog(f"Sucessfully validated installer hash ({setup_hash})")
else:
consoleLog("Error: Invalid Filehash, file may be corrupted")
sys.exit(0)
else:
consoleLog("Skipping Hash Verification (no hash found for release)")
progress.setLabelText("Installing update...")
progress.setValue(100)
QtWidgets.QApplication.processEvents()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
sys.exit(0)
def windowCloseHelper():
QGuiApplication.quit()
class ElidedItemDelegate(QStyledItemDelegate):
def __init__(self, get_hovered_row, parent=None):
super().__init__(parent)
@@ -274,17 +209,17 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
state.version = build_info.get("version")
if state.ignore_updates is False and platform.system() == "Windows":
assets = get_updates()
assets, latest = get_updates()
if assets != None:
msg = QMessageBox()
msg.setIcon(QMessageBox.Icon.Information)
msg.setWindowTitle("Update Available")
msg.setText("A new version is available.")
msg.setInformativeText("Press Ok to download the update.")
msg.setText(f"A new version is available\n({latest})")
msg.setInformativeText("Press Ok to download.")
msg.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Ignore)
response = msg.exec_()
if response == QMessageBox.StandardButton.Ok:
_download_update(assets)
download_update(assets)
self.setWindowTitle("Software Manager")
self.setGeometry(100, 100, 800, 600)
@@ -488,22 +423,40 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
def setEditorData(self, editor, index):
button = editor.findChild(QtWidgets.QPushButton)
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
if button:
if status.state == lt.torrent_status.seeding:
button.setIcon(svg_icon(SVG_FOLDER, 18))
button.setText("")
else:
is_user_paused = status.paused and not status.auto_managed
button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
button.setText("")
if not button:
return
try:
with state.downloads_lock:
keys = list(state.active_downloads.keys())
if index.row() >= len(keys):
return
magnet_link = keys[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (RuntimeError, IndexError, KeyError):
return
if status.state == lt.torrent_status.seeding:
button.setIcon(svg_icon(SVG_FOLDER, 18))
button.setText("")
else:
is_user_paused = status.paused and not status.auto_managed
button.setIcon(svg_icon(SVG_PLAY if is_user_paused else SVG_PAUSE, 18))
button.setText("")
def createEditor(self, parent, option, index):
magnet_link = list(state.active_downloads.keys())[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
try:
with state.downloads_lock:
keys = list(state.active_downloads.keys())
if index.row() >= len(keys):
return QWidget(parent)
magnet_link = keys[index.row()]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
except (RuntimeError, IndexError, KeyError):
return QWidget(parent)
widget = QWidget(parent)
widget.setStyleSheet("border: none; background: transparent;")
layout = QHBoxLayout(widget)
@@ -526,6 +479,7 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
widget.setLayout(layout)
return widget
def editorEvent(self, event, model, option, index):
return False
@@ -846,10 +800,14 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
if idx.isValid():
editor = self.downloadList.indexWidget(idx)
if editor and delegate:
delegate.setEditorData(editor, idx)
try:
delegate.setEditorData(editor, idx)
except RuntimeError:
pass
self._update_speed_label()
def _update_speed_label(self):
total_down = 0
total_up = 0
@@ -991,63 +949,89 @@ class MainWindow(QtWidgets.QMainWindow, QWidget):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
confirm = QMessageBox.question(self, "Cancel Download", f"Are you sure you want to cancel the download of '{magnetdl.status().name}'?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
# Cache the name BEFORE removing from session
try:
torrent_name = magnetdl.status().name
except RuntimeError:
torrent_name = "Unknown"
confirm = QMessageBox.question(
self, "Cancel Download",
f"Are you sure you want to cancel the download of '{torrent_name}'?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
if hasattr(magnetdl, 'stop'):
magnetdl.stop()
elif state.dl_session:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
except Exception:
pass
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
try:
consoleLog(f"Cancelled download: {magnetdl.status().name}", True)
except RuntimeError:
consoleLog(f"Cancelled download")
consoleLog(f"Cancelled download: {torrent_name}", True)
def deleteFileAction(self):
if not hasattr(self, '_context_menu_row'):
return
row = self._context_menu_row
if row < 0 or row >= len(state.active_downloads):
with state.downloads_lock:
if row < 0 or row >= len(state.active_downloads):
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
try:
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
except RuntimeError:
consoleLog("Error: torrent handle already invalid", True)
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
return
magnet_link = list(state.active_downloads.keys())[row]
magnetdl = state.active_downloads[magnet_link]
status = magnetdl.status()
save_path = status.save_path
torrent_name = status.name
download_path = os.path.join(save_path, torrent_name)
confirm = QMessageBox.question(self, "Delete Files", f"Are you sure you want to delete the downloaded files of '{magnetdl.status().name}'? This action cannot be undone.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
confirm = QMessageBox.question(
self, "Delete Files",
f"Are you sure you want to delete the downloaded files of '{torrent_name}'? This action cannot be undone.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if confirm == QMessageBox.StandardButton.Yes:
if hasattr(magnetdl, 'stop'):
magnetdl.stop()
elif state.dl_session:
with state.downloads_lock:
state.active_downloads.pop(magnet_link, None)
remove_download_log(magnet_link)
if state.dl_session:
try:
state.dl_session.remove_torrent(magnetdl)
time.sleep(0.5)
except Exception:
pass
if download_path and os.path.exists(download_path):
try:
if os.path.isfile(download_path):
remove_download_log(magnet_link)
os.remove(download_path)
del state.active_downloads[magnet_link]
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
else:
import shutil
remove_download_log(magnet_link)
shutil.rmtree(download_path)
del state.active_downloads[magnet_link]
consoleLog(f"Deleted files for: {magnetdl.status().name}", True)
except Exception as e:
consoleLog(f"Error deleting files: {e}", True)
import shutil
last_error = None
for attempt in range(3):
try:
if os.path.isfile(download_path):
os.remove(download_path)
else:
shutil.rmtree(download_path)
consoleLog(f"Deleted files for: {torrent_name}", True)
last_error = None
break
except Exception as e:
last_error = e
if attempt < 2:
time.sleep(0.5)
if last_error:
consoleLog(f"Error deleting files: {last_error}", True)
else:
del state.active_downloads[magnet_link]
remove_download_log(magnet_link)
consoleLog(f"Removed entry (files not found): {magnetdl.status().name}", True)
consoleLog(f"Removed entry (files not found): {torrent_name}", True)
+1 -1
View File
@@ -89,7 +89,7 @@ def add_download(magnet_uri):
try:
params = lt.parse_magnet_uri(magnet_uri)
params.save_path = "."
params.save_path = state.download_path
handle = state.dl_session.add_torrent(params)
+2 -3
View File
@@ -14,13 +14,12 @@ def check_completed(downloads, resume):
if download.completed == False:
consoleLog(f"Found unfinished download: {download.title}")
if resume == True:
dl_dir = os.path.dirname(download.path)
if download.magnet_uri:
run_download_direct(download.magnet_uri, dl_dir, download.title)
run_download_direct(download.magnet_uri, download.title)
consoleLog(f"Resuming Magnet: {download.title}")
elif download.url:
from core.network.direct_download import add_direct_download
add_direct_download(download.url, download.title, dl_dir)
add_direct_download(download.url, download.title)
consoleLog(f"Resuming Direct Download: {download.title}")
def check_downloads(downloads):
+1 -1
View File
@@ -41,7 +41,7 @@ def run_download(post):
else:
add_direct_download(link, post.get("title", "Unknown"))
def run_download_direct(magnet_uri, dl_path=None, title="Direct Download"):
def run_download_direct(magnet_uri, title="Direct Download"):
consoleLog(f"Magnet: {title}")
add_magnet(magnet_uri)
add_download_log(title, "", magnet_uri, False)
+3 -4
View File
@@ -4,7 +4,6 @@ from core.utils.logging.logs import consoleLog
def get_updates():
url = f"https://api.github.com/repos/KeksPirates/SoftwareManager/releases/latest"
try:
response = requests.get(url, timeout=15)
except requests.RequestException as e:
@@ -33,9 +32,9 @@ def get_updates():
url=asset['browser_download_url'],
hash=asset.get('digest')
))
return release_assets
return release_assets, latest_version
else:
return None
return None, None
else:
consoleLog("Already up-to-date.")
return None
return None, None
+96
View File
@@ -0,0 +1,96 @@
from core.network.direct_download.handle import DirectDownloadHandle
from core.utils.logging.logs import consoleLog
from core.utils.data.state import state
from PySide6.QtCore import Qt
from PySide6 import QtWidgets
import libtorrent as lt
import subprocess
import tempfile
import hashlib
import time
import sys
import os
def _verify_hash(file_path: str, expected_hash: str) -> bool:
sha256 = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}" == expected_hash
def download_update(assets: list):
filename = None
setup_hash = None
url = None
for asset in assets:
if "-windows-setup.exe" in asset["name"]:
filename = asset["name"]
setup_hash = asset["hash"]
url = asset["url"]
break
if not filename:
consoleLog("Error: No Windows installer found in release assets")
return
installer_path = os.path.join(tempfile.gettempdir(), filename)
progress = QtWidgets.QProgressDialog("Downloading update... (0.0 MB/s)", None, 0, 100)
progress.setWindowTitle("Updating")
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
progress.setCancelButton(None)
progress.setMinimumDuration(0)
progress.setAutoClose(False)
progress.setAutoReset(False)
progress.setValue(0)
progress.show()
QtWidgets.QApplication.processEvents()
original_limit = state.down_speed_limit
state.down_speed_limit = 0
handle = DirectDownloadHandle(url, filename, tempfile.gettempdir())
handle.start()
while True:
QtWidgets.QApplication.processEvents()
status = handle.status()
if status.error:
state.down_speed_limit = original_limit
progress.close()
consoleLog(f"Update download failed: {status.error}")
return
if status.total_wanted > 0:
pct = int(status.total_wanted_done * 100 / status.total_wanted)
speed_mb = status.download_rate / (1024 * 1024)
progress.setValue(pct)
progress.setLabelText(f"Downloading update... ({speed_mb:.1f} MB/s)")
if status.state == lt.torrent_status.seeding:
break
time.sleep(0.1)
state.down_speed_limit = original_limit
if setup_hash:
if _verify_hash(installer_path, setup_hash):
consoleLog(f"Successfully validated installer hash ({setup_hash})")
else:
progress.close()
consoleLog("Error: Invalid file hash, file may be corrupted")
sys.exit(0)
else:
consoleLog("Skipping hash verification (no hash found for release)")
progress.setLabelText("Installing update...")
progress.setValue(100)
QtWidgets.QApplication.processEvents()
subprocess.Popen([installer_path, "/VERYSILENT", "/SUPPRESSMSGBOXES", "/SP-", "/CLOSEAPPLICATIONS"])
time.sleep(1)
sys.exit(0)