This commit is contained in:
2025-04-17 13:56:26 +02:00
parent cee644ffd3
commit 16520043a9
8 changed files with 207 additions and 106 deletions

2
.idea/misc.xml generated
View File

@@ -3,7 +3,7 @@
<component name="Black"> <component name="Black">
<option name="sdkName" value="Python 3.13 (oxapp25)" /> <option name="sdkName" value="Python 3.13 (oxapp25)" />
</component> </component>
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 (oxapp25) (2)" project-jdk-type="Python SDK" /> <component name="ProjectRootManager" version="2" project-jdk-name="Python 3.13 virtualenv at D:\Dev\oxapp25\.venv" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser"> <component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" /> <option name="version" value="3" />
</component> </component>

2
.idea/oxapp25.iml generated
View File

@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
</content> </content>
<orderEntry type="jdk" jdkName="Python 3.13 (oxapp25) (2)" jdkType="Python SDK" /> <orderEntry type="jdk" jdkName="Python 3.13 virtualenv at D:\Dev\oxapp25\.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" /> <orderEntry type="sourceFolder" forTests="false" />
</component> </component>
</module> </module>

79
main.py
View File

@@ -1,6 +1,4 @@
from PySide6.QtCore import QStandardPaths, QDataStream, QByteArray, QIODevice, Signal, Qt, QTimer, QCryptographicHash
from PySide6.QtCore import QStandardPaths, QDataStream, QByteArray, QIODevice, Signal, Qt
from PySide6.QtNetwork import QLocalServer, QLocalSocket from PySide6.QtNetwork import QLocalServer, QLocalSocket
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
@@ -10,6 +8,10 @@ import asyncio
import os import os
import platform import platform
import argparse import argparse
import hashlib
import random
import string
import base64
from pathlib import Path from pathlib import Path
from src.logs import configure_logging from src.logs import configure_logging
@@ -23,6 +25,7 @@ class SingleApplication(QApplication):
def __init__(self, app_id, args): def __init__(self, app_id, args):
super().__init__(args) super().__init__(args)
self.app_id = app_id self.app_id = app_id
self.shared_key = hashlib.sha256(app_id.encode()).hexdigest()[:16]
self.server = None self.server = None
self.is_primary_instance = self.try_connect_to_primary() self.is_primary_instance = self.try_connect_to_primary()
@@ -34,6 +37,52 @@ class SingleApplication(QApplication):
# En cas d'erreur (serveur déjà existant mais zombie), on le supprime et on réessaie # En cas d'erreur (serveur déjà existant mais zombie), on le supprime et on réessaie
QLocalServer.removeServer(self.app_id) QLocalServer.removeServer(self.app_id)
self.server.listen(self.app_id) self.server.listen(self.app_id)
else:
QTimer.singleShot(0, self.quit)
def encrypt_data(self, data_str):
"""Méthode simple pour brouiller les données"""
# Générer une "nonce" aléatoire pour éviter que les mêmes données produisent le même résultat
nonce = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(8))
# Combiner la nonce, la clé et les données
combined = nonce + self.shared_key + data_str
# Utiliser SHA-256 pour obtenir un hash
hash_obj = QCryptographicHash(QCryptographicHash.Algorithm.Sha256)
hash_obj.addData(combined.encode())
signature = hash_obj.result().toHex().data().decode()[:16]
# Encoder le tout en base64
encoded = base64.b64encode((nonce + signature + data_str).encode()).decode()
return encoded
def decrypt_data(self, encoded_str):
"""Déchiffre les données et vérifie leur intégrité"""
try:
# Décoder de base64
decoded = base64.b64decode(encoded_str.encode()).decode()
# Extraire nonce, signature et données
nonce = decoded[:8]
signature = decoded[8:24]
data_str = decoded[24:]
# Vérifier la signature
combined = nonce + self.shared_key + data_str
hash_obj = QCryptographicHash(QCryptographicHash.Algorithm.Sha256)
hash_obj.addData(combined.encode())
expected_signature = hash_obj.result().toHex().data().decode()[:16]
if signature != expected_signature:
print("Signature invalide, données potentiellement corrompues ou falsifiées")
return None
return data_str
except Exception as e:
print(f"Erreur lors du déchiffrement: {e}")
return None
def try_connect_to_primary(self): def try_connect_to_primary(self):
"""Essaie de se connecter à l'instance primaire de l'application""" """Essaie de se connecter à l'instance primaire de l'application"""
@@ -43,26 +92,35 @@ class SingleApplication(QApplication):
if socket.waitForConnected(500): if socket.waitForConnected(500):
# Récupérer les arguments pour les envoyer à l'instance primaire # Récupérer les arguments pour les envoyer à l'instance primaire
args = sys.argv[1:] if len(sys.argv) > 1 else [] args = sys.argv[1:] if len(sys.argv) > 1 else []
encrypt_args = self.encrypt_data(";".join(args))
# Envoyer les arguments à l'instance primaire # Envoyer les arguments à l'instance primaire
stream = QDataStream(socket) stream = QDataStream(socket)
stream.writeQString(";".join(args)) stream.writeQString(encrypt_args)
socket.flush() socket.flush()
socket.waitForBytesWritten(1000)
socket.disconnectFromServer() socket.disconnectFromServer()
QTimer.singleShot(0, self.quit)
return False # Ce n'est pas l'instance primaire return False # Ce n'est pas l'instance primaire
return True # C'est l'instance primaire return True # C'est l'instance primaire
def handle_new_connection(self): def handle_new_connection(self):
"""Gère une nouvelle connexion d'une instance secondaire""" """Gère une nouvelle connexion d'une instance secondaire"""
socket = self.server.nextPendingConnection() socket = self.server.nextPendingConnection()
if socket.waitForReadyRead(1000):
if socket.waitForReadyRead(2000):
stream = QDataStream(socket) stream = QDataStream(socket)
args_str = stream.readQString()
args = args_str.split(";") if args_str else [] encrypted_args = stream.readQString()
args_str = self.decrypt_data(encrypted_args)
# Émettre un signal pour informer l'application des fichiers à ouvrir # Émettre un signal pour informer l'application des fichiers à ouvrir
if args: if args_str:
self.files_received.emit(args) args = args_str.split(";") if args_str else []
if args:
self.files_received.emit(args)
socket.disconnectFromServer() socket.disconnectFromServer()
@@ -84,6 +142,9 @@ if __name__ == "__main__":
app_id = "OxAPP25" app_id = "OxAPP25"
app = SingleApplication(app_id, sys.argv) app = SingleApplication(app_id, sys.argv)
if not app.is_primary_instance:
sys.exit(0)
event_loop = qasync.QEventLoop(app) event_loop = qasync.QEventLoop(app)
asyncio.set_event_loop(event_loop) asyncio.set_event_loop(event_loop)

View File

@@ -31,13 +31,6 @@ class ConfManager(QObject):
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.DEBUG) # Niveau DEBUG pour capturer tous les messages self.logger.setLevel(logging.DEBUG) # Niveau DEBUG pour capturer tous les messages
# S'assurer qu'un gestionnaire de log est présent
if not self.logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
self.logger.addHandler(handler)
self.logger.info("Initialisation de ConfManager") self.logger.info("Initialisation de ConfManager")
# Verrou pour les opérations de fichier (thread-safety) # Verrou pour les opérations de fichier (thread-safety)

View File

@@ -1,3 +1,5 @@
import traceback
from PySide6.QtCore import QObject, Signal, QTimer from PySide6.QtCore import QObject, Signal, QTimer
from PySide6.QtNetwork import QNetworkCookie from PySide6.QtNetwork import QNetworkCookie
@@ -13,6 +15,7 @@ import httpx
from src.datatypes import FileType, FileStatsType from src.datatypes import FileType, FileStatsType
from src.async_file import async_open from src.async_file import async_open
from src.utils import aexec_in
if TYPE_CHECKING: if TYPE_CHECKING:
from windows.main_window import MainWindow from windows.main_window import MainWindow
@@ -36,7 +39,7 @@ class DownloadManager(QObject):
self.task_stats: dict[str, FileStatsType] = {} self.task_stats: dict[str, FileStatsType] = {}
self.waiter = asyncio.Event() self.waiter = asyncio.Event()
self.client_session: None|httpx.AsyncClient = None self.client_session: None|httpx.AsyncClient = None
self.cookies = [] self.cookies = {}
# slots # slots
# self.status_updated.connect(lambda data: self.conf.set_value("files", self.files)) # self.status_updated.connect(lambda data: self.conf.set_value("files", self.files))
@@ -55,21 +58,26 @@ class DownloadManager(QObject):
self.status = {} self.status = {}
self.update_status() self.update_status()
async def initialize(self): # async def initialize(self):
self.client_session = httpx.AsyncClient( # self.client_session = httpx.AsyncClient(
timeout=httpx.Timeout(None), # timeout=httpx.Timeout(
follow_redirects=True, # connect=5.0, # 5 secondes pour établir la connexion
verify=False, # read=None, # Pas de timeout pour la lecture des données (téléchargement)
# http2=True, # write=60.0, # 60 secondes pour envoyer des données
) # pool=5.0 # 5 secondes pour obtenir une connexion du pool
# ),
for cookie in self.cookies: # follow_redirects=True,
await self.add_cookie(cookie) # verify=False,
self.logger.info("Session aiohttp initialisée") # # http2=True,
# )
#
# for cookie in self.cookies:
# await self.add_cookie(cookie)
# self.logger.info("Session aiohttp initialisée")
async def loop_queue(self): async def loop_queue(self):
if self.client_session is None: # if self.client_session is None:
await self.initialize() # await self.initialize()
self.logger.info("Démarrage de la boucle de téléchargement") self.logger.info("Démarrage de la boucle de téléchargement")
while True: while True:
@@ -78,6 +86,8 @@ class DownloadManager(QObject):
else: else:
file = await self.next_file() file = await self.next_file()
if file is None: if file is None:
if not self.tasks:
await self.set_pause(True)
await self.wait() await self.wait()
else: else:
self.tasks[file] = asyncio.create_task(self.download_file(file)) self.tasks[file] = asyncio.create_task(self.download_file(file))
@@ -89,22 +99,52 @@ class DownloadManager(QObject):
self.logger.info("loop queue resumed") self.logger.info("loop queue resumed")
def set_pause(self, value): async def set_pause(self, value):
if self.pause == value: if self.pause == value:
return return
self.pause = value self.pause = value
if self.pause: try:
for file_id, task in self.tasks.items(): if self.pause:
if not task.done(): self.logger.info("Essaie de la mise en pause")
task.cancel()
self.logger.info("Tous les téléchargements ont été mis en pause") # on attend 0.5 sec pour voir si le téléchargement s'interrompt proprement avec le self.pause.
else: asyncio.create_task(aexec_in(1, self._clean_connections))
self.pause = False # await asyncio.sleep(0.5)
self.waiter.set() # for task in self.tasks.values():
self.logger.info("Reprise des téléchargements") # if task and not task.done():
# task.cancel()
# # Attendre que les tâches se terminent proprement
# await asyncio.gather(*[t for t in self.tasks.values() if t and not t.done()],
# return_exceptions=True)
else:
self.pause = False
self.waiter.set()
self.logger.info("Reprise des téléchargements")
except Exception as e:
self.logger.error(f"Erreur lors de la mise en pause: {e}")
self.update_status() self.update_status()
async def _clean_connections(self):
"""Fonction asynchrone interne pour nettoyer les connexions lors de la pause"""
self.logger.debug("cleaning connections")
try:
# Annuler proprement les tâches en cours
for file, task in self.tasks.items():
self.logger.error(f"task for {file.target} not cancelled: {task}")
if task and not task.done():
task.cancel()
self.logger.error(f"trying to cancel task for {file.target}")
# Attendre que les tâches se terminent
if self.tasks.values():
await asyncio.wait([t for t in self.tasks.values() if t and not t.done()],
timeout=2.0)
except Exception as e:
self.logger.error(f"Erreur lors du nettoyage des connexions: {e}")
async def next_file(self) -> FileType | None: async def next_file(self) -> FileType | None:
self.logger.debug("Recherche du prochain fichier à télécharger") self.logger.debug("Recherche du prochain fichier à télécharger")
for file in self.files.values(): for file in self.files.values():
@@ -138,43 +178,60 @@ class DownloadManager(QObject):
headers.update({"Range": f"bytes={stats.downloaded_size}-{stats.total_size}"}) headers.update({"Range": f"bytes={stats.downloaded_size}-{stats.total_size}"})
mode: Literal["ab", "wb"] = "ab" if stats.downloaded_size > 0 else "wb" mode: Literal["ab", "wb"] = "ab" if stats.downloaded_size > 0 else "wb"
# Initilisation de la session:
try: try:
async with self.client_session.stream("GET", file.url, headers=headers) as response: async with httpx.AsyncClient(
async with await anyio.open_file(file_path, mode) as f: timeout=httpx.Timeout(connect=5.0, read=None, write=60.0, pool=5.0),
follow_redirects=True,
verify=False,
cookies=self.cookies,
) as client:
# requête pour le téléchargement
async with client.stream("GET", file.url, headers=headers) as response:
# on trigger les bad requests
response.raise_for_status() response.raise_for_status()
# on ouvre le fichier pour commencer à écrire
async with await anyio.open_file(file_path, mode) as f:
last_update_time = time.monotonic() last_update_time = time.monotonic()
last_downloaded_size = stats.downloaded_size last_downloaded_size = stats.downloaded_size
async for chunk in response.aiter_bytes(self.chunk_size):
if not chunk:
break
await f.write(chunk)
if self.pause:
break
chunk_size = len(chunk)
stats.downloaded_size += chunk_size
current_time = time.monotonic() async for chunk in response.aiter_bytes(self.chunk_size):
elapsed_time = current_time - last_update_time if self.pause:
await response.aclose()
break
if elapsed_time >= 1.0: if not chunk:
bytes_downloaded = stats.downloaded_size - last_downloaded_size break
current_speed = bytes_downloaded / elapsed_time
if stats.speed > 0: await f.write(chunk)
stats.speed = round(0.7 * current_speed + 0.3 * stats.speed) chunk_size = len(chunk)
stats.downloaded_size += chunk_size
current_time = time.monotonic()
elapsed_time = current_time - last_update_time
if elapsed_time >= 1.0:
bytes_downloaded = stats.downloaded_size - last_downloaded_size
current_speed = bytes_downloaded / elapsed_time
if stats.speed > 0:
stats.speed = round(0.7 * current_speed + 0.3 * stats.speed)
else:
stats.speed = round(current_speed)
last_update_time = current_time
last_downloaded_size = stats.downloaded_size
else: else:
stats.speed = round(current_speed) await asyncio.sleep(0.005)
last_update_time = current_time
last_downloaded_size = stats.downloaded_size
else:
await asyncio.sleep(0.005)
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
self.logger.error(f"Erreur HTTP lors du téléchargement de {file.target}: {e.response.status_code} - {e}") self.logger.error(
f"Erreur HTTP lors du téléchargement de {file.target}: {e.response.status_code} - {e}")
file.error = f"Erreur HTTP {e.response.status_code}: {str(e)}" file.error = f"Erreur HTTP {e.response.status_code}: {str(e)}"
except httpx.TimeoutException as e: except httpx.TimeoutException as e:
self.logger.error(f"Délai d'attente dépassé lors du téléchargement de {file.target}: {str(e)}") self.logger.error(
f"Délai d'attente dépassé lors du téléchargement de {file.target}: {str(e)}")
file.error = f"Délai d'attente dépassé: {str(e)}" file.error = f"Délai d'attente dépassé: {str(e)}"
except httpx.ConnectError as e: except httpx.ConnectError as e:
@@ -198,13 +255,17 @@ class DownloadManager(QObject):
file.error = f"Erreur d'E/S: {str(e)}" file.error = f"Erreur d'E/S: {str(e)}"
except Exception as e: except Exception as e:
self.logger.error(f"Erreur inattendue lors du téléchargement de {file.target}: {type(e).__name__} - {str(e)}") self.logger.error(
f"Erreur inattendue lors du téléchargement de {file.target}: {type(e).__name__} - {str(e)}")
print(traceback.format_exc())
file.error = f"Erreur inattendue: {str(e)}" file.error = f"Erreur inattendue: {str(e)}"
else: else:
file.downloaded = True if self.pause:
self.logger.info(f"Téléchargement de {file.target} terminé avec succès") self.logger.info(f"Téléchargement de {file.target} mis en pause")
else:
file.downloaded = True
self.logger.info(f"Téléchargement de {file.target} terminé avec succès")
finally: finally:
await self.task_ended(file) await self.task_ended(file)
@@ -253,29 +314,21 @@ class DownloadManager(QObject):
self.files_updated.emit(self.files) self.files_updated.emit(self.files)
def update_status(self): def update_status(self):
self.status = { new_status = {
"pause": self.pause, "pause": self.pause,
"max_worker": self.max_worker, "max_worker": self.max_worker,
"total_files": len(self.files), "total_files": len(self.files),
"downloaded_files": sum(file.downloaded for file in self.files.values() if file.downloaded), "downloaded_files": sum(file.downloaded for file in self.files.values() if file.downloaded),
"downloading": [task.id for task in self.tasks.keys()], "downloading": [task.id for task in self.tasks.keys()],
"total_size": sum(file.total_size for file in self.files.values()), "total_size": sum(file.total_size for file in self.files.values()),
"downloaded_size": sum(file.total_size for file in self.files.values() if file.downloaded) + sum((dl_stat.downloaded_size for dl_stat in self.task_stats.values()), 0), # "downloaded_size": sum(file.total_size for file in self.files.values() if file.downloaded) + sum((dl_stat.downloaded_size for dl_stat in self.task_stats.values()), 0),
"downloaded_size": sum(file.size_downloaded for file in self.files.values()),
"speed": sum((dl_stat.speed for dl_stat in self.task_stats.values()), 0), "speed": sum((dl_stat.speed for dl_stat in self.task_stats.values()), 0),
"downloader_stats": {key: dl_stat.to_dict() for key, dl_stat in self.task_stats.items()} "downloader_stats": {key: dl_stat.to_dict() for key, dl_stat in self.task_stats.items()}
} }
self.status_updated.emit(self.status) if self.status != new_status:
self.status = new_status
# def update_dl_stats(self): self.status_updated.emit(self.status)
# old_stats = deepcopy(self.dl_stats)
# self.dl_stats = {
# "speed": sum((dl_stat.speed for dl_stat in self.task_stats.values()), 0),
# "downloaded_size": sum((dl_stat.downloaded_size for dl_stat in self.task_stats.values()), 0),
# "downloading_stats": {key: dl_stat.to_dict() for key, dl_stat in self.task_stats.items()},
# }
# if old_stats != self.dl_stats:
# self.stats_updated.emit(self.dl_stats)
# return self.dl_stats
async def add_cookie(self, cookie: QNetworkCookie): async def add_cookie(self, cookie: QNetworkCookie):
""" """
@@ -284,16 +337,8 @@ class DownloadManager(QObject):
Args: Args:
cookie: Un objet QNetworkCookie de PySide6 cookie: Un objet QNetworkCookie de PySide6
""" """
if self.client_session is None:
# Si la session n'est pas encore initialisée, stocker le cookie pour plus tard
self.cookies.append(cookie)
return
# Extraction des informations essentielles du QNetworkCookie # Extraction des informations essentielles du QNetworkCookie
name = cookie.name().data().decode() name = cookie.name().data().decode()
value = cookie.value().data().decode() value = cookie.value().data().decode()
self.cookies.update({name: value})
# Ajout direct du cookie sans attributs supplémentaires
self.client_session.cookies[name] = value
self.logger.info(f"Cookie ajouté: {name}={value}") self.logger.info(f"Cookie ajouté: {name}={value}")

View File

@@ -1,3 +1,5 @@
import asyncio
from PySide6.QtCore import QObject, Slot, Signal, QTimer, Property from PySide6.QtCore import QObject, Slot, Signal, QTimer, Property
import json import json
@@ -51,12 +53,12 @@ class WebHandler(QObject):
@Slot(result=str) @Slot(result=str)
def on_site_ready(self): def on_site_ready(self):
self.site_ready = True self.site_loaded = True
self.on_site_ready.emit() self.site_ready.emit()
@Slot(bool) @Slot(bool)
def set_pause(self, value): def set_pause(self, value):
self.download_manager.set_pause(value) asyncio.create_task(self.download_manager.set_pause(value))
@Slot(list) @Slot(list)
@Slot(str) @Slot(str)
@@ -70,7 +72,7 @@ class WebHandler(QObject):
@Slot(list) @Slot(list)
def del_files(self, file_ids): def del_files(self, file_ids):
if isinstance(file_ids, str): if isinstance(file_ids, str):
file_ids = json.loads(file_ids) file_ids = [file_ids]
self.download_manager.del_files(file_ids) self.download_manager.del_files(file_ids)
@Slot() @Slot()

View File

@@ -1,3 +1,5 @@
import asyncio
from PySide6.QtNetwork import QNetworkCookie from PySide6.QtNetwork import QNetworkCookie
import time import time
@@ -94,3 +96,7 @@ class RestrictedUnpickler(pickle.Unpickler):
raise pickle.UnpicklingError(f"Accès refusé à la classe {module}.{name} pour des raisons de sécurité") raise pickle.UnpicklingError(f"Accès refusé à la classe {module}.{name} pour des raisons de sécurité")
async def aexec_in(secs, func):
await asyncio.sleep(secs)
return await func()

View File

@@ -34,7 +34,6 @@ class SiteWindow(QWebEngineView):
self.persistent_profile = QWebEngineProfile("OxAppProfile", parent=self) self.persistent_profile = QWebEngineProfile("OxAppProfile", parent=self)
self.cookie_store = self.persistent_profile.cookieStore() self.cookie_store = self.persistent_profile.cookieStore()
self.cookie_store.cookieAdded.connect(self.on_cookie_added.emit) self.cookie_store.cookieAdded.connect(self.on_cookie_added.emit)
self.cookie_store.cookieAdded.connect(self.test_cookie)
self.persistent_profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.MemoryHttpCache) self.persistent_profile.setHttpCacheType(QWebEngineProfile.HttpCacheType.MemoryHttpCache)
self.persistent_profile.setPersistentStoragePath(str(self.conf.app_config_path / "web_cache")) self.persistent_profile.setPersistentStoragePath(str(self.conf.app_config_path / "web_cache"))
self.persistent_profile.setPersistentCookiesPolicy(QWebEngineProfile.PersistentCookiesPolicy.AllowPersistentCookies) self.persistent_profile.setPersistentCookiesPolicy(QWebEngineProfile.PersistentCookiesPolicy.AllowPersistentCookies)
@@ -60,14 +59,9 @@ class SiteWindow(QWebEngineView):
self.load(parent.url) self.load(parent.url)
def test_cookie(self, *args, **kwargs):
print("cook", *args, **kwargs)
# self.cookie_store.loadAllCookies()
@Slot(bool) @Slot(bool)
def on_load_finished(self, is_success): def on_load_finished(self, is_success):
print("load finished")
if is_success: if is_success:
api_file = QFile(":/qtwebchannel/qwebchannel.js") api_file = QFile(":/qtwebchannel/qwebchannel.js")
api_file.open(QIODevice.OpenModeFlag.ReadOnly) api_file.open(QIODevice.OpenModeFlag.ReadOnly)