Compare commits

..

17 Commits

28 changed files with 1472 additions and 352 deletions

30
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,30 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Python Debugger: Attach using Process Id",
"type": "debugpy",
"request": "attach",
"processId": "${command:pickProcess}"
},
{
"name": "Python Debugger: Current File",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal"
},
{
"name": "Python Debugger: Attach",
"type": "debugpy",
"request": "attach",
"connect": {
"host": "localhost",
"port": 5678
}
}
]
}

View File

@@ -8,7 +8,11 @@ a = Analysis(
('.\\styles\\styles.qss', 'styles'),
('.\\ui\\mainwindow_vertical_pager.ui', 'ui')
],
hiddenimports=[],
hiddenimports=[
"asyncio",
"pypresence",
"pypresence.baseclient",
],
hookspath=[],
hooksconfig={
"qt_plugins": ["platforms", "styles"]
@@ -107,8 +111,8 @@ a = Analysis(
# Concurrency non utilisée dans ton code
"multiprocessing",
"concurrent",
"asyncio",
#"concurrent",
#"asyncio",
# REPL / terminal
"readline",
@@ -218,6 +222,15 @@ a.binaries = [
if not any(u.lower() in name.lower() for u in unwanted_dlls)
]
# AJOUTE CECI ICI :
# On filtre la liste des fichiers de données (datas)
# On exclut tout ce qui se trouve dans le dossier 'translations' de PySide6
a.datas = [f for f in a.datas if "translations" not in f[0].lower()]
# Si tu veux aussi supprimer les traductions système de Qt (fichiers .qm)
a.datas = [f for f in a.datas if not f[0].endswith('.qm')]
pyz = PYZ(a.pure, a.zipped_data)
exe = EXE(

5
assets/no_whitelist.svg Normal file
View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path color="#000000" d="M7.992 0A2.008 2.008 0 0 0 6 2H5c-.657 0-1.178.06-1.617.225-.439.164-.79.461-.998.838-.415.752-.37 1.673-.385 2.931v5.012c.015 1.258-.03 2.179.385 2.932.208.376.56.673.998.838.439.164.96.224 1.617.224h3.762a4.5 4.5 0 0 1-.498-1H5c-.592 0-1.005-.063-1.265-.16-.26-.098-.372-.203-.473-.387C3.06 13.087 3.015 12.259 3 11V6c.015-1.259.06-2.087.262-2.453.101-.184.213-.29.473-.387C3.995 3.062 4.408 3 5 3v.999h6V3c.593 0 1.006.063 1.266.16.26.098.371.203.473.387.201.366.247 1.194.261 2.453v2.031a4.5 4.5 0 0 1 1 .233v-2.27c-.015-1.258.031-2.179-.385-2.932a1.88 1.88 0 0 0-.998-.837C12.179 2.06 11.657 2 11 2H9.996a2.008 2.008 0 0 0-1.992-2zm.01 1c.559 0 1 .442 1 1a.99.99 0 0 1-1 1 .982.982 0 0 1-.922-.61A1.01 1.01 0 0 1 7.003 2c0-.558.441-1 1-1zM5 6v1h6.012V6zm0 2v1h4.674a4.5 4.5 0 0 1 1.338-.74V8zm-.01 2v1h3v-1z" fill="gray" font-family="sans-serif" font-weight="400" overflow="visible" style="line-height:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;text-transform:none;isolation:auto;mix-blend-mode:normal;marker:none" white-space="normal"/>
<path class="error" color="#000000" d="M12.5 9A3.5 3.5 0 0 0 9 12.5a3.5 3.5 0 0 0 3.5 3.5 3.5 3.5 0 0 0 3.5-3.5A3.5 3.5 0 0 0 12.5 9zm-.5 1h1v1.168c0 .348-.016.667-.047.957-.03.29-.069.581-.115.875h-.666a12.898 12.898 0 0 1-.125-.875 9.146 9.146 0 0 1-.047-.957zm.5 4a.5.5 0 0 1 .5.5.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5z" fill="#f22c42" overflow="visible" style="marker:none"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -49,3 +49,10 @@ Execute `python install.py`
Note: in case if you aren't virtual environment, the script stop.
Enjoy.
## Generate resource file
`rcc -g python .\resources.qrc -o .\src\resources_rc.py`
You need to give complete path of rcc:
`H:\Qt\6.10.2\mingw_64\bin\rcc.exe -g python resources.qrc -o .\src\resources.py`

View File

@@ -1,5 +1,6 @@
<RCC>
<qresource prefix="/">
<file>assets/no_whitelist.svg</file>
<file>assets/closed-store-info.svg</file>
<file>assets/letter-i-info.svg</file>
<file>assets/open-store-info.svg</file>

56
src/config/constants.py Normal file
View File

@@ -0,0 +1,56 @@
from enum import Enum
from dataclasses import dataclass
from PySide6.QtGui import QColor
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
FIVEMURL = "fivem://connect/prod.la-taniere.fun"
REDIRECT_URI = "http://localhost:5000/callback"
SCOPES = ["identify"]
CLIENT_ID = "1240007913175781508"
AUTENTICATION_SUCCESS_MESSAGE = """
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
</head>
<body>
<h1>Authentication réussie</h1>
<p>Vous pouvez maintenant fermer cette fenêtre et revenir au launcher de La Tanière.</p>
</body>
</html>
""".encode('utf-8')
# ---------------------------------------------------------------------------
# ENUMS
# ---------------------------------------------------------------------------
class Resources(Enum):
MP3 = ':/assets/the-beat-of-nature.mp3'
FONT = ':/assets/Avocado-Cake-Demo.otf'
class Urls(Enum):
DISCORD = 'https://discord.gg/A7eanmSkp2'
INTRANET = 'https://la-taniere.fun/connexion/'
API_URL = 'https://prod.la-taniere.fun:30121/'
class ApiEndPoints(Enum):
QUEUE_STATUS = '/queue/status/'
QUEUE_LEAVE = '/queue/leave'
QUEUE_JOIN = '/queue/join'
REGISTER_USER = '/api_v2/connection/register'
class Glow(Enum):
COLOR = QColor(255, 140, 0, 255)
BLUR_BASE = 15
BLUR_PEAK = 70
ANIM_DURATION = 1200
# ---------------------------------------------------------------------------
# DATACLASS
# ---------------------------------------------------------------------------
@dataclass
class PlayerServerInfo:
is_staff: bool = False
is_whitelist: bool = False
session_id: str = None

View File

@@ -3,7 +3,7 @@ from PySide6.QtMultimedia import QMediaPlayer, QAudioOutput
from config.config_manager import ConfigManager, VOLUME_KEY
from tools.constants import Resources
from config.constants import Resources
class AudioController:
# Encapsule toute la logique audio : lecture, volume, mute.

View File

@@ -1,7 +1,7 @@
from PySide6.QtCore import QPropertyAnimation, QEasingCurve
from PySide6.QtWidgets import QGraphicsDropShadowEffect
from tools.constants import Glow
from config.constants import Glow
class GlowAnimator:

View File

@@ -0,0 +1,113 @@
import os
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs, urlencode, urlparse
from config.constants import AUTENTICATION_SUCCESS_MESSAGE, CLIENT_ID, REDIRECT_URI, SCOPES
from fivemserver.get_server_token import GetServerTokenForDiscord
from tools.http_client import ApiError, http_get, http_post
# Disable stderr output
os.environ['PYTHONWARNINGS'] = 'ignore'
OAUTH_AUTHORIZE_URL = "https://discord.com/api/oauth2/authorize"
OAUTH_TOKEN_URL = "https://discord.com/api/oauth2/token"
DISCORD_ME_URL = "https://discord.com/api/users/@me"
LOCAL_CALLBACK_HOST = "localhost"
LOCAL_CALLBACK_PORT = 5000
class OAuthCallbackHandler(BaseHTTPRequestHandler):
code: str | None = None
# Ajoute ceci pour empêcher le serveur d'écrire dans la console/stderr
def log_message(self, format, *args):
return # Ne fait rien, donc pas de blocage sur stdout/stderr
def do_GET(self):
"""
callback pour discord auth
"""
parsed_url = urlparse(self.path)
if parsed_url.path != "/callback":
self.send_error(404)
return
query_params = parse_qs(parsed_url.query)
OAuthCallbackHandler.code = query_params.get("code", [None])[0]
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(AUTENTICATION_SUCCESS_MESSAGE)
# return discord application id (client id)
def get_discord_client_id() -> str:
"""
return discord application id
"""
return CLIENT_ID
# return discord user id
def get_discord_user_id() -> tuple[str, str]:
"""
Retourne l'id du compte discord de l'utilisateur via l'oauh discord.
"""
# récupération des infos serveur la tanière
session_id = GetServerTokenForDiscord.authenticate()
client_secret = GetServerTokenForDiscord.get_token(session_id)
auth_url = "https://discord.com/api/oauth2/authorize"
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": " ".join(SCOPES),
}
webbrowser.open(f"{auth_url}?{urlencode(params)}")
server = HTTPServer((LOCAL_CALLBACK_HOST, LOCAL_CALLBACK_PORT), OAuthCallbackHandler)
try:
server.handle_request()
finally:
server.server_close()
if not OAuthCallbackHandler.code:
raise ApiError("OAuth échoué : aucun code de validation reçu.")
try:
token = http_post(
OAUTH_TOKEN_URL,
data={
"client_id": CLIENT_ID,
"client_secret": client_secret,
"grant_type": "authorization_code",
"code": OAuthCallbackHandler.code,
"redirect_uri": REDIRECT_URI,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
).json()
except ValueError as exc:
raise ApiError("OAuth échoué : réponse JSON invalide lors de la récupération du token.",
url=OAUTH_TOKEN_URL) from exc
access_token = token.get("access_token")
if not access_token:
raise ApiError("OAuth échoué : access_token manquant.", url=OAUTH_TOKEN_URL)
try:
user = http_get(
DISCORD_ME_URL,
headers={"Authorization": f"Bearer {access_token}"},
).json()
except ValueError as exc:
raise ApiError("OAuth échoué : réponse JSON invalide lors de la récupération du profil Discord.",
url=DISCORD_ME_URL) from exc
user_id = user.get("id")
if not user_id:
raise ApiError("OAuth échoué : id utilisateur manquant.", url=DISCORD_ME_URL)
return user_id, session_id

View File

@@ -0,0 +1,53 @@
import psutil
from pypresence import Presence
from fivemserver.get_server_token import GetServerTokenForDiscord
from config.constants import Urls
from discord.discord_oauth import CLIENT_ID
class DiscordToken:
"""
Décode le token discord
"""
@staticmethod
def decode_discord_token():
discord_token = GetServerTokenForDiscord.get_token(
GetServerTokenForDiscord.authenticate(Urls.API_URL.value)
)
return discord_token
class CheckDiscord:
@staticmethod
def isdiscordrunning() -> bool:
"""
Vérifie si Discord est en cours d'exécution sur l'ordinateur. (Vérifie aussi pour Linux)
"""
for process in psutil.process_iter(["name"]):
if (
process.info["name"].lower() == "discord.exe"
or process.info["name"].lower() == "discordcanary.exe"
or process.info["name"].lower() == "discord"
or process.info["name"].lower() == "discord canary"
):
return True
return False
@staticmethod
def isuserconnected() -> bool:
"""
Vérifie si l'utilisateur Discord est connecté.
ne vérifie pas le user id discord.
"""
rpc = Presence(CLIENT_ID)
try:
rpc.connect()
return True
except Exception as e:
return False
finally:
try:
rpc.close()
except Exception as e:
pass

View File

@@ -0,0 +1,20 @@
import subprocess
import os
from config.constants import FIVEMURL
class FiveMLauncher:
def __init__(self, fivem_path: str):
self.fivem_path = os.path.expandvars(fivem_path)
@staticmethod
def launch():
"""
if not os.path.exists(self.fivem_path):
raise FileNotFoundError("❌ FiveM.exe introuvable")
subprocess.Popen(self.fivem_path, shell=True)
"""
#subprocess.Popen(f"explorer {FIVEMURL}")
subprocess.Popen(r'explorer fivem://connect/prod.la-taniere.fun')

View File

@@ -0,0 +1,113 @@
import base64
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from config.constants import Urls
from tools.http_client import http_post, ApiError
class GetServerTokenForDiscord:
derived_key: bytes | None = None
@staticmethod
def authenticate(server: str | None = Urls.API_URL.value) -> str:
if server is None:
server = Urls.API_URL.value
# ==========================
# Génération clé ECDH client
# ==========================
client_private = ec.generate_private_key(ec.SECP256R1())
client_public = client_private.public_key()
client_pub_pem = client_public.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# ==========================
# AUTH
# ==========================
try:
auth = http_post(
f"{server}/api_v2/auth",
json={"client_pub": base64.b64encode(client_pub_pem).decode()},
).json()
except ApiError:
raise
except ValueError as exc:
raise ApiError("Réponse JSON invalide lors de l'authentification.", url=f"{server}/api_v2/auth") from exc
server_pub_b64 = auth.get("server_pub")
session_id = auth.get("session_id")
if not server_pub_b64 or not session_id:
raise ApiError("Réponse d'authentification invalide : données manquantes.", url=f"{server}/api_v2/auth")
server_pub = serialization.load_pem_public_key(base64.b64decode(server_pub_b64))
shared_key = client_private.exchange(ec.ECDH(), server_pub)
GetServerTokenForDiscord.derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"fivem-private-server"
).derive(shared_key)
return auth["session_id"]
@staticmethod
def get_token(session_id: bytes, server: str | None = Urls.API_URL.value) -> bytes:
# ==========================
# DISCORD TOKEN
# ==========================
if server is None:
server = Urls.API_URL.value
if GetServerTokenForDiscord.derived_key is None:
raise ApiError("Clé dérivée manquante : appelez authenticate() avant get_token().")
try:
download = http_post(
f"{server}/api_v2/tkn_auth",
headers={"x-session-id": session_id},
).json()
except ApiError:
raise
except ValueError as exc:
raise ApiError("Réponse JSON invalide lors de la récupération du token.",
url=f"{server}/api_v2/tkn_auth") from exc
nonce_b64 = download.get("nonce")
encrypted_data_b64 = download.get("data")
if not nonce_b64 or not encrypted_data_b64:
raise ApiError("Réponse de token invalide : nonce ou data manquant.", url=f"{server}/api_v2/tkn_auth")
nonce = base64.b64decode(nonce_b64)
encrypted_data = base64.b64decode(encrypted_data_b64)
aesgcm = AESGCM(GetServerTokenForDiscord.derived_key) # type: ignore[arg-type]
return aesgcm.decrypt(nonce, encrypted_data, None)
@staticmethod
def register_discord_user(
discord_user_id: str, session_id: str, server: str | None = Urls.API_URL.value) -> bool:
if server is None:
server = Urls.API_URL.value
try:
registration_data = http_post(
f"{server}/api_v2/connection/register",
headers={"x-session-id": session_id},
json={"discord_id": discord_user_id},
).json()
except ApiError:
raise
except ValueError as exc:
raise ApiError(
"Réponse JSON invalide lors de l'enregistrement Discord.",
url=f"{server}/api_v2/connection/register",
) from exc
return bool(registration_data.get("success", False))

View File

@@ -0,0 +1,57 @@
# queue_manager.py — Aucune dépendance Qt
import requests
import time
from typing import Callable
from config.constants import Urls, ApiEndPoints
class QueueManager:
def __init__(self, user_id: str, on_update: Callable[[str], None]):
self.user_id = user_id
self.on_update = on_update # Callback pour envoyer les mises à jour
self._running = True
def stop(self):
self._running = False
def join_queue(self) -> dict:
res = requests.post(
f"{Urls.API_URL.value}{ApiEndPoints.QUEUE_JOIN.value}",
json={"uuid": self.user_id},
verify=False
)
return res.json()
def check_status(self) -> dict:
res = requests.get(f"{Urls.API_URL.value}{ApiEndPoints.QUEUE_STATUS.value}/{self.user_id}")
return res.json()
def leave_queue(self):
requests.post(
f"{Urls.API_URL.value}{ApiEndPoints.QUEUE_LEAVE.value}",
json={"uuid": self.user_id},
verify=False
)
def start(self):
join = self.join_queue()
# print(f"[QueueManager] join response: {join}") # ← Debug
if join.get("position") == 0: # Position 0 = slot libre
self.on_update("ok")
return
self.on_update(f"position:{join.get('position')}:{join.get('queueSize')}")
while self._running:
time.sleep(5)
if not self._running:
break
status = self.check_status()
if status.get("position") == 0: # Position 0 = c'est le tour
self.on_update("ready")
return
else:
self.on_update(f"position:{status.get('position')}:{status.get('queueSize')}")

View File

@@ -0,0 +1,22 @@
from tools.http_client import ApiError, http_get
from config.constants import PlayerServerInfo
WHITELIST_URL_ENDPOINT = "iswhitelist/"
class WhiteList:
@staticmethod
def check_whitelist(url: str, discord_user_id: str) -> None:
try:
api_data = http_get(f"{url}{WHITELIST_URL_ENDPOINT}{discord_user_id}").json()
except ApiError:
raise
except ValueError as exc:
raise ApiError(
"Réponse JSON invalide lors de la vérification whitelist.",
url=f"{url}{WHITELIST_URL_ENDPOINT}{discord_user_id}",
) from exc
PlayerServerInfo.is_whitelist = api_data.get("whitelisted", False)
PlayerServerInfo.is_staff = api_data.get("isStaff", False)

View File

@@ -7,16 +7,18 @@ from PySide6.QtWidgets import QApplication
# Imports pour la gestion de la configuration
from config.config_manager import ConfigManager
from config.constants import Resources
# Imports pour la vérification Discord
from tools.discord_tools import CheckDiscord
from tools.custom_message_box import CustomMessageBox
from discord.discord_tools import CheckDiscord
# Import pour la partie ui
from ui.custom_message_box import CustomMessageBox
from ui.main_window import MainWindow
# Ne pas supprimer ! Enregistre les ressources Qt
import resources # noqa: F401 - required to register Qt resources
from ui.main_window import MainWindow
from tools.constants import Resources
# ---------------------------------------------------------------------------
# Bundle path resolution
@@ -60,7 +62,7 @@ if __name__ == "__main__":
# 3. Garde-fou Discord
if not CheckDiscord.isdiscordrunning():
msg = CustomMessageBox(
title="Launcher La Tanière: Discord non détecté",
title="La Tanière: Discord non détecté",
message="Discord ne semble pas lancé.\n\n"
"Tu dois avoir démarré Discord et y être connecté pour utiliser l'application.\n\n"
"Lorsque cela sera fait, relance le launcher.",
@@ -72,10 +74,10 @@ if __name__ == "__main__":
# On récupère l'ID stocké (sera "" si absent grâce au schéma)
stored_user_id = config.get_discord_user()
if stored_user_id != "": # si pas encore d'id dans la config
if not CheckDiscord.isuserconnected(stored_user_id):
if stored_user_id != "" and not stored_user_id.isspace():
if not CheckDiscord.isuserconnected():
msg = CustomMessageBox(
title="Launcher La Tanière: connexion Discord",
title="La Tanière: connexion Discord",
message="Tu n'est pas connecté à Discord\n\n"
"Assure-toi que tu es connecté à Discord.\n\n"
"Lorsque cela sera fait, relance le launcher.",

View File

@@ -181151,6 +181151,119 @@ ill:#60C3AB;\x22 cx\
=\x22225.067\x22 cy=\x224\
43.733\x22 r=\x2211.73\
3\x22/>\x0d\x0a</svg>\
\x00\x00\x06\xe2\
<\
?xml version=\x221.\
0\x22 encoding=\x22utf\
-8\x22?><!-- Upload\
ed to: SVG Repo,\
www.svgrepo.com\
, Generator: SVG\
Repo Mixer Tool\
s -->\x0a<svg width\
=\x22800px\x22 height=\
\x22800px\x22 viewBox=\
\x220 0 16 16\x22 xmln\
s=\x22http://www.w3\
.org/2000/svg\x22>\x0d\
\x0a <path color\
=\x22#000000\x22 d=\x22M7\
.992 0A2.008 2.0\
08 0 0 0 6 2H5c-\
.657 0-1.178.06-\
1.617.225-.439.1\
64-.79.461-.998.\
838-.415.752-.37\
1.673-.385 2.93\
1v5.012c.015 1.2\
58-.03 2.179.385\
2.932.208.376.5\
6.673.998.838.43\
9.164.96.224 1.6\
17.224h3.762a4.5\
4.5 0 0 1-.498-\
1H5c-.592 0-1.00\
5-.063-1.265-.16\
-.26-.098-.372-.\
203-.473-.387C3.\
06 13.087 3.015 \
12.259 3 11V6c.0\
15-1.259.06-2.08\
7.262-2.453.101-\
.184.213-.29.473\
-.387C3.995 3.06\
2 4.408 3 5 3v.9\
99h6V3c.593 0 1.\
006.063 1.266.16\
.26.098.371.203.\
473.387.201.366.\
247 1.194.261 2.\
453v2.031a4.5 4.\
5 0 0 1 1 .233v-\
2.27c-.015-1.258\
.031-2.179-.385-\
2.932a1.88 1.88 \
0 0 0-.998-.837C\
12.179 2.06 11.6\
57 2 11 2H9.996a\
2.008 2.008 0 0 \
0-1.992-2zm.01 1\
c.559 0 1 .442 1\
1a.99.99 0 0 1-\
1 1 .982.982 0 0\
1-.922-.61A1.01\
1.01 0 0 1 7.00\
3 2c0-.558.441-1\
1-1zM5 6v1h6.01\
2V6zm0 2v1h4.674\
a4.5 4.5 0 0 1 1\
.338-.74V8zm-.01\
2v1h3v-1z\x22 fill\
=\x22gray\x22 font-fam\
ily=\x22sans-serif\x22\
font-weight=\x2240\
0\x22 overflow=\x22vis\
ible\x22 style=\x22lin\
e-height:normal;\
text-indent:0;te\
xt-align:start;t\
ext-decoration-l\
ine:none;text-de\
coration-style:s\
olid;text-decora\
tion-color:#0000\
00;text-transfor\
m:none;isolation\
:auto;mix-blend-\
mode:normal;mark\
er:none\x22 white-s\
pace=\x22normal\x22/>\x0d\
\x0a <path class\
=\x22error\x22 color=\x22\
#000000\x22 d=\x22M12.\
5 9A3.5 3.5 0 0 \
0 9 12.5a3.5 3.5\
0 0 0 3.5 3.5 3\
.5 3.5 0 0 0 3.5\
-3.5A3.5 3.5 0 0\
0 12.5 9zm-.5 1\
h1v1.168c0 .348-\
.016.667-.047.95\
7-.03.29-.069.58\
1-.115.875h-.666\
a12.898 12.898 0\
0 1-.125-.875 9\
.146 9.146 0 0 1\
-.047-.957zm.5 4\
a.5.5 0 0 1 .5.5\
.5.5 0 0 1-.5.5.\
5.5 0 0 1-.5-.5.\
5.5 0 0 1 .5-.5z\
\x22 fill=\x22#f22c42\x22\
overflow=\x22visib\
le\x22 style=\x22marke\
r:none\x22/>\x0d\x0a</svg\
>\
"
qt_resource_name = b"\
@@ -181217,12 +181330,16 @@ qt_resource_name = b"\
\x0cA\xab\xe7\
\x00c\
\x00o\x00m\x00p\x00u\x00t\x00e\x00r\x00-\x00t\x00v\x00.\x00s\x00v\x00g\
\x00\x10\
\x0b[_\x07\
\x00n\
\x00o\x00_\x00w\x00h\x00i\x00t\x00e\x00l\x00i\x00s\x00t\x00.\x00s\x00v\x00g\
"
qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0d\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x0e\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x01\xba\x00\x00\x00\x00\x00\x01\x00+\xdd\xc1\
\x00\x00\x01\x9c\x0ft\xac\xa3\
@@ -181240,6 +181357,8 @@ qt_resource_struct = b"\
\x00\x00\x01\x9c\xd8\xa1X\x0f\
\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x01\x00(\xfb*\
\x00\x00\x01\x9c\xd9\xa2\xfa\xf9\
\x00\x00\x02\x0e\x00\x00\x00\x00\x00\x01\x00,7\xc6\
\x00\x00\x01\x9d\x16B\x01\xc7\
\x00\x00\x00\xb2\x00\x00\x00\x00\x00\x01\x00)\x83\x02\
\x00\x00\x01\x9c\xed\xb0@\xf2\
\x00\x00\x01\xea\x00\x00\x00\x00\x00\x01\x00,1\x15\

View File

@@ -1,28 +0,0 @@
from enum import Enum
from PySide6.QtGui import QColor
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
NO_STAFF = True
NO_DISCORD = True
# ---------------------------------------------------------------------------
# ENUMS
# ---------------------------------------------------------------------------
class Resources(Enum):
MP3 = ":/assets/the-beat-of-nature.mp3"
FONT = ":/assets/Avocado-Cake-Demo.otf"
class Urls(Enum):
DISCORD = "https://discord.gg/A7eanmSkp2"
INTRANET = "https://la-taniere.fun/connexion/"
API_URL = 'https://prod.la-taniere.fun:30121/'
class Glow(Enum):
COLOR = QColor(255, 140, 0, 255)
BLUR_BASE = 15
BLUR_PEAK = 70
ANIM_DURATION = 1200

View File

@@ -1,87 +0,0 @@
import requests
import webbrowser
import os
from urllib.parse import urlencode
from http.server import HTTPServer, BaseHTTPRequestHandler
from get_server_token import GetServerTokenForDiscord
# Disable stderr output
os.environ['PYTHONWARNINGS'] = 'ignore'
REDIRECT_URI = "http://localhost:5000/callback"
SCOPES = ["identify"]
CLIENT_ID = "1240007913175781508"
AUTENTICATION_SUCCESS_MESSAGE = """
<html>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
</head>
<body>
<h1>Authentication réussie</h1>
<p>Vous pouvez maintenant fermer cette fenêtre et revenir au launcher de La Tanière.</p>
</body>
</html>
""".encode('utf-8')
class OAuthCallbackHandler(BaseHTTPRequestHandler):
code: str | None = None
def do_GET(self):
if "/callback" in self.path:
query = self.path.split("?")[1]
params = dict(p.split("=") for p in query.split("&"))
OAuthCallbackHandler.code = params.get("code")
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(AUTENTICATION_SUCCESS_MESSAGE)
# return discord application id (client id)
def get_discord_client_id() -> str:
return CLIENT_ID
# return discord user id
def get_discord_user_id() -> str:
# récupération des infos serveur lataupe
session_id = GetServerTokenForDiscord.authenticate()
client_secret = GetServerTokenForDiscord.get_token(session_id)
auth_url = "https://discord.com/api/oauth2/authorize"
params = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": " ".join(SCOPES),
}
webbrowser.open(f"{auth_url}?{urlencode(params)}")
server = HTTPServer(("localhost", 5000), OAuthCallbackHandler)
# celle ligne cache le stderr output pour ne pas afficher l'url de callback dans la console
# valable en debug mode
os.dup2(os.open(os.devnull, os.O_WRONLY), 2)
server.handle_request()
if not OAuthCallbackHandler.code:
raise RuntimeError("OAuth échoué")
token = requests.post(
"https://discord.com/api/oauth2/token",
data={
"client_id": CLIENT_ID,
"client_secret": client_secret,
"grant_type": "authorization_code",
"code": OAuthCallbackHandler.code,
"redirect_uri": REDIRECT_URI,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
).json()
user = requests.get(
"https://discord.com/api/users/@me",
headers={"Authorization": f"Bearer {token['access_token']}"},
).json()
return user["id"]

View File

@@ -1,37 +0,0 @@
import psutil
from pypresence import Presence
from tools.get_server_token import GetServerTokenForDiscord
from tools.constants import Urls
class DiscordToken:
@staticmethod
def decode_discord_token():
discord_token = GetServerTokenForDiscord.get_token(GetServerTokenForDiscord.authenticate(Urls.API_URL.value))
return discord_token
class CheckDiscord:
@staticmethod
def isdiscordrunning() -> bool:
for process in psutil.process_iter(["name"]):
if (process.info["name"].lower() == "discord.exe" or
process.info["name"].lower() == "discordcanary.exe" or
process.info["name"].lower() == "discord" or
process.info["name"].lower() == "discord canary"):
return True
return False
@staticmethod
def isuserconnected(clientid: str) -> bool:
rpc = Presence(clientid)
try:
rpc.connect()
return True
except Exception:
return False
finally:
try:
rpc.close()
except Exception:
pass

View File

@@ -1,76 +0,0 @@
import base64
import requests
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
API_URL = 'https://prod.la-taniere.fun:30121/'
class GetServerTokenForDiscord:
derived_key: bytes | None = None
@staticmethod
def authenticate(server = API_URL):
if server is None:
server = API_URL
# ==========================
# Génération clé ECDH client
# ==========================
client_private = ec.generate_private_key(ec.SECP256R1())
client_public = client_private.public_key()
client_pub_pem = client_public.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# ==========================
# AUTH
# ==========================
auth = requests.post(server + "/api_v2/auth", verify=False, json={
"client_pub": base64.b64encode(client_pub_pem).decode()
}).json()
server_pub = serialization.load_pem_public_key(
base64.b64decode(auth["server_pub"])
)
shared_key = client_private.exchange(ec.ECDH(), server_pub)
GetServerTokenForDiscord.derived_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"fivem-private-server"
).derive(shared_key)
return auth["session_id"]
@staticmethod
def get_token(session_id: bytes, server = API_URL):
# ==========================
# DISCORD TOKEN
# ==========================
if server is None:
server = API_URL
download = requests.post(server + "/api_v2/tkn_auth", verify=False, headers={
"x-session-id": session_id
}).json()
nonce = base64.b64decode(download["nonce"])
encrypted_data = base64.b64decode(download["data"])
aesgcm = AESGCM(GetServerTokenForDiscord.derived_key) # type: ignore[arg-type]
return aesgcm.decrypt(nonce, encrypted_data, None)
# @staticmethod
# def register_discord_user(user_id: str, server = API_URL) -> str:
# if server is None:
# server = API_URL
# registeredId = requests.post(server + "/api_v2/connection/register", verify=False, json={
# "x-session-id": user_id
# }).json()
#
# return registeredId["discord_id"]

73
src/tools/http_client.py Normal file
View File

@@ -0,0 +1,73 @@
from __future__ import annotations
import logging
from typing import Any, Literal
import requests
DEFAULT_TIMEOUT = 15
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
logger = logging.getLogger(__name__)
class ApiError(RuntimeError):
def __init__(self, message: str, *, url: str | None = None, status_code: int | None = None) -> None:
super().__init__(message)
self.url = url
self.status_code = status_code
def http_request(
method: HttpMethod,
url: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> requests.Response:
try:
response = requests.request(
method=method,
url=url,
headers=headers,
params=params,
data=data,
json=json,
verify=False,
timeout=timeout,
)
response.raise_for_status()
return response
except requests.RequestException as exc:
status_code = getattr(getattr(exc, "response", None), "status_code", None)
logger.warning("HTTP %s failed for %s (%s)", method, url, status_code or "no-status")
raise ApiError(
f"Erreur HTTP sur {method} {url}",
url=url,
status_code=status_code,
) from exc
def http_get(
url: str,
*,
headers: dict[str, str] | None = None,
params: dict[str, Any] | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> requests.Response:
return http_request("GET", url, headers=headers, params=params, timeout=timeout)
def http_post(
url: str,
*,
headers: dict[str, str] | None = None,
data: dict[str, Any] | None = None,
json: dict[str, Any] | None = None,
timeout: int = DEFAULT_TIMEOUT,
) -> requests.Response:
return http_request("POST", url, headers=headers, data=data, json=json, timeout=timeout)

View File

@@ -1,5 +1,6 @@
import sys
from pathlib import Path
from PySide6.QtWidgets import QApplication
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -17,3 +18,10 @@ def get_executable_dir() -> Path:
# sys.executable est le chemin complet vers l'application .exe
return Path(sys.executable).parent.resolve()
return Path(__file__).resolve().parents[2]
def quit_application(exit_code: int = 0) -> None:
app = QApplication.instance()
if app is not None:
app.closeAllWindows()
app.exit(exit_code)
sys.exit(exit_code)

View File

@@ -1,4 +1,3 @@
import sys
from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QWidget, QGraphicsDropShadowEffect)
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve
@@ -20,9 +19,6 @@ class CustomMessageBox(QDialog):
self.setAttribute(Qt.WA_TranslucentBackground)
self.setMinimumWidth(400)
color_main = "#101624"
color_accent = "#248277" if icon_type == self.INFO else "#cf5b16"
# --- ANIMATION DE FONDU ---
self.setWindowOpacity(0)
self.fade_anim = QPropertyAnimation(self, b"windowOpacity")
@@ -33,16 +29,10 @@ class CustomMessageBox(QDialog):
# --- UI SETUP ---
self.container = QWidget(self)
self.container.setObjectName("MainContainer")
self.container.setStyleSheet(f"""
QWidget#MainContainer {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 {color_main}, stop:1 {color_accent});
border-radius: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}}
QLabel {{ color: white; font-family: 'Segoe UI'; }}
""")
self.container.setObjectName("MsgBoxMainContainer")
# Utilisé dans le fichier QSS comme condition dynamique de style
self.container.setProperty("iconType", icon_type)
self.container.setProperty("buttonsType", buttons)
# LAYOUT PRINCIPAL DU CONTAINER (Marges à 0 pour coller le bouton au bord)
layout = QVBoxLayout(self.container)
@@ -55,28 +45,13 @@ class CustomMessageBox(QDialog):
title_bar_layout.setSpacing(0)
title_label = QLabel(title.upper())
title_label.setStyleSheet(
"font-weight: bold; font-size: 10px; color: rgba(255,255,255,0.7); letter-spacing: 1px;")
title_label.setObjectName("MsgBoxTitleLabel")
self.close_btn = QPushButton("")
self.close_btn.setObjectName("MsgBoxCloseButton")
self.close_btn.setFixedSize(45, 35)
self.close_btn.clicked.connect(self.reject)
self.close_btn.setCursor(Qt.PointingHandCursor)
self.close_btn.setStyleSheet("""
QPushButton {
background: transparent;
color: white;
border: none;
font-size: 14px;
/* Rayon identique au container (15px) pour épouser parfaitement le coin */
border-top-right-radius: 15px;
border-bottom-left-radius: 10px;
}
QPushButton:hover {
background-color: #e74c3c;
color: white;
}
""")
title_bar_layout.addWidget(title_label)
title_bar_layout.addStretch()
@@ -91,12 +66,12 @@ class CustomMessageBox(QDialog):
# Contenu central (Icône + Message)
content_layout = QHBoxLayout()
icon_label = QLabel()
icon_label.setObjectName("MsgBoxIconLabel")
icon_text = "" if icon_type == self.INFO else "⚠️"
icon_label.setText(icon_text)
icon_label.setStyleSheet("font-size: 35px; margin-right: 10px;")
msg_label = QLabel(message)
msg_label.setStyleSheet("font-size: 14px; color: #f0f0f0;")
msg_label.setObjectName("MsgBoxMessageLabel")
msg_label.setWordWrap(True)
content_layout.addWidget(icon_label)
@@ -108,24 +83,14 @@ class CustomMessageBox(QDialog):
btn_layout.setSpacing(10)
btn_layout.addStretch()
style_btn_base = """
QPushButton {
background: #2a313d; border-radius: 6px; color: white;
padding: 8px 20px; font-weight: bold; font-size: 12px;
border: 1px solid rgba(255,255,255,0.05);
}
QPushButton:hover { background: #363d4a; border: 1px solid white; }
"""
if buttons == self.OK_CANCEL:
self.btn_cancel = QPushButton("ANNULER")
self.btn_cancel.setStyleSheet(style_btn_base)
self.btn_cancel.setObjectName("MsgBoxCancelButton")
self.btn_cancel.clicked.connect(self.reject)
btn_layout.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("COMPRIS")
style_btn_ok = style_btn_base.replace("#2a313d", "#248277")
self.btn_ok.setStyleSheet(style_btn_ok)
self.btn_ok.setObjectName("MsgBoxOkButton")
self.btn_ok.setCursor(Qt.PointingHandCursor)
self.btn_ok.clicked.connect(self.accept)
btn_layout.addWidget(self.btn_ok)

14
src/ui/error_dialog.py Normal file
View File

@@ -0,0 +1,14 @@
from PySide6.QtWidgets import QWidget
from ui.custom_message_box import CustomMessageBox
def show_qt_error(parent: QWidget | None, title: str, message: str) -> None:
msg = CustomMessageBox(
title=title,
message=message,
icon_type=CustomMessageBox.WARNING,
buttons=CustomMessageBox.OK,
parent=parent,
)
msg.exec()

75
src/ui/hazard_stripes.py Normal file
View File

@@ -0,0 +1,75 @@
# ui/hazard_stripes.py
from PySide6.QtWidgets import QPushButton, QStyleOptionButton, QStyle
from PySide6.QtGui import QPainter, QColor, QPainterPath, QPen, QPolygon
from PySide6.QtCore import Qt, QPoint
class HazardButton(QPushButton):
def __init__(self, parent=None):
super().__init__(parent)
self._hazard = False
def set_hazard(self, enabled: bool):
self._hazard = enabled
self.update()
def paintEvent(self, event):
if not hasattr(self, '_hazard'):
self.__dict__['_hazard'] = False
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
r = self.rect()
radius = 4
path = QPainterPath()
path.addRoundedRect(r, radius, radius)
p.setClipPath(path)
if self._hazard:
p.fillRect(r, QColor("#FFD700"))
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor("#000000"))
stripe_width = 20
stripe_gap = 30
period = stripe_width + stripe_gap
diag = r.width() + r.height()
for x in range(-diag, diag * 2, period):
stripe = QPolygon([
QPoint(x, r.bottom() + 10),
QPoint(x + stripe_width, r.bottom() + 10),
QPoint(x + stripe_width + r.height() + 10, r.top() - 10),
QPoint(x + r.height() + 10, r.top() - 10),
])
p.drawPolygon(stripe)
# ↓ Fond semi-transparent derrière le texte
text_bg = QColor(255, 215, 0, 230) # noir à 63% d'opacité
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(text_bg)
bg_rect = r.adjusted(60, 8, -60, -8) # marges internes
p.drawRoundedRect(bg_rect, 4, 4)
else:
p.fillRect(r, QColor("#FFD700"))
p.setClipping(False)
p.setPen(QPen(QColor("#000000"), 2))
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawRoundedRect(r.adjusted(1, 1, -1, -1), radius, radius)
p.setClipping(False)
opt = QStyleOptionButton()
self.initStyleOption(opt)
opt.palette.setColor(
opt.palette.ColorRole.ButtonText,
self.palette().color(self.palette().ColorRole.ButtonText)
)
self.style().drawControl(
QStyle.ControlElement.CE_PushButtonLabel, opt, p, self
)

View File

@@ -6,12 +6,21 @@ from PySide6 import QtGui
from PySide6.QtCore import Qt
from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QMainWindow, QSizePolicy
from PySide6.QtCore import QThread, Signal
from config.config_manager import ConfigManager
from config.constants import PlayerServerInfo, Urls
from tools.http_client import ApiError
from ui.error_dialog import show_qt_error
from ui.hazard_stripes import HazardButton
from controllers.audio_controller import AudioController
from controllers.glow_animator import GlowAnimator
from controllers.window_dragger import WindowDragger
from tools.constants import NO_DISCORD, NO_STAFF, Urls
from discord import discord_oauth
from fivemserver.whitelistmanager import WhiteList
from fivemserver.fivemlauncher import FiveMLauncher
from fivemserver.queuemanager import QueueManager
from fivemserver.get_server_token import GetServerTokenForDiscord
from fake_patch_notes import patch_note
# For Linux Wayland to authorize moving window
@@ -19,10 +28,14 @@ if platform.startswith('linux'):
environ["QT_QPA_PLATFORM"] = "xcb"
class MainWindow(QMainWindow):
#update = Signal(str) # Reçoit les callbacks de QueueManager
def __init__(self, bundle_dir: str, config_manager: ConfigManager):
super().__init__()
self.config = config_manager
self.stored_user_id = self.config.get_discord_user()
self.queue_thread = None
# UI
self.ui = QUiLoader().load(f"{bundle_dir}/ui/mainwindow_vertical_pager.ui", self)
@@ -30,8 +43,60 @@ class MainWindow(QMainWindow):
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
# Par défaut on affiche la page normal pour la connexion au serveur
self.ui.stackedWidget.setCurrentIndex(0)
# On cache par défaut les infos liste d'attente
self.ui.queue_lbl.hide()
self.ui.queue_position.hide()
# Si l'id discord = "" ou des espace, alors on affiche la page comme quoi faut être connecté à discord.
if self.stored_user_id == "" or self.stored_user_id.isspace():
self.ui.stackedWidget.setCurrentIndex(1)
else:
try:
# on vérifie si le joueur est whitelisté
WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id)
except ApiError as exc:
show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}")
PlayerServerInfo.is_whitelist = False
PlayerServerInfo.is_staff = False
# si on est whitelisté, on démarre la file d'attente
if PlayerServerInfo.is_whitelist:
self.start_queue()
self.ui.queue_lbl.show()
self.ui.queue_position.show()
else:
self.ui.stackedWidget.setCurrentIndex(2)
# Test bouton en contruction
en_chantier = False
# on set la css du bouton en fonction de la valeur de la variable en_chantier
self.set_en_chantier(en_chantier)
if en_chantier:
old_btn = self.ui.connexion_btn
parent_layout = self.ui.verticalLayout_6 # layout direct du bouton dans le .ui
index = parent_layout.indexOf(old_btn)
new_btn = HazardButton(old_btn.parentWidget())
new_btn.setObjectName("connexion_btn")
new_btn.setText("EN MAINTENANCE")
new_btn.setIcon(old_btn.icon())
new_btn.setIconSize(old_btn.iconSize())
new_btn.setMinimumSize(old_btn.minimumSize())
new_btn.set_hazard(True)
parent_layout.takeAt(index)
old_btn.deleteLater()
parent_layout.insertWidget(index, new_btn)
self.ui.connexion_btn = new_btn
self.ui.connexion_btn.clicked.connect(self._on_connexion)
# centrage vertical du bouton connexion
if NO_STAFF:
if not PlayerServerInfo.is_staff:
self.ui.staff_btn.hide()
layout = self.ui.verticalLayout_6
# Trouver et modifier le spacer item
@@ -41,12 +106,6 @@ class MainWindow(QMainWindow):
item.spacerItem().changeSize(20, 15, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
layout.invalidate() # Forcer le recalcul du layout
break
# self.ui.spacer_substitution.hide()
if NO_DISCORD:
self.ui.queue_lbl.hide()
self.ui.queue_position.hide()
self.ui.stackedWidget.setCurrentIndex(1)
self.ui.info_text.setMarkdown(patch_note)
@@ -57,8 +116,10 @@ class MainWindow(QMainWindow):
self._connect_signals()
self._center_window()
self.show()
# ------------------------------------------------------------------
# Setup
# ------------------------------------------------------------------
@@ -69,8 +130,8 @@ class MainWindow(QMainWindow):
self.ui.connexion_btn.clicked.connect(self._on_connexion)
self.ui.discord_btn.clicked.connect(self._on_discord)
self.ui.intranet_btn.clicked.connect(self._on_intranet)
self.ui.discord_auth_btn.clicked.connect(self._on_discord_auth_btn)
self.ui.no_whitelist_btn.clicked.connect(self.close)
def _center_window(self) -> None:
self.adjustSize()
@@ -87,7 +148,12 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------
def _on_connexion(self) -> None:
pass # à implémenter
try:
session_id = GetServerTokenForDiscord.authenticate(Urls.API_URL.value)
GetServerTokenForDiscord.register_discord_user(self.stored_user_id, session_id)
FiveMLauncher.launch()
except ApiError as exc:
show_qt_error(self, "Connexion impossible", f"Erreur lors de la connexion.\n\n{exc}")
@staticmethod
def _on_discord() -> None:
@@ -98,8 +164,14 @@ class MainWindow(QMainWindow):
self._glow.start()
def _on_discord_auth_btn(self) -> None:
self.ui.stackedWidget.setCurrentIndex(0)
try:
test = discord_oauth.get_discord_user_id()
self.config.set_discord_user(test[0])
PlayerServerInfo.session_id = test[1]
self.config.save()
self.ui.stackedWidget.setCurrentIndex(0)
except ApiError as exc:
show_qt_error(self, "Connexion Discord", f"Impossible de récupérer ton compte Discord.\n\n{exc}")
# ------------------------------------------------------------------
# Mouse events → délégués au WindowDragger
# ------------------------------------------------------------------
@@ -121,5 +193,70 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------
def closeEvent(self, event) -> None:
if self.queue_thread and self.queue_thread.isRunning():
self.queue_thread.stop()
self.queue_thread.wait() # Attend que le thread se termine proprement
self.config.save()
super().closeEvent(event)
# ------------------------------------------------------------------
# Change ui on runtime
# ------------------------------------------------------------------
def set_en_chantier(self, valeur: bool):
self.en_chantier = valeur # ta variable Python
self.ui.connexion_btn.setProperty("en_chantier", valeur) # propriété Qt
self.ui.connexion_btn.style().unpolish(self.ui.connexion_btn)
self.ui.connexion_btn.style().polish(self.ui.connexion_btn)
# ------------------------------------------------------------------
# Queue managment
# ------------------------------------------------------------------
def start_queue(self):
self.queue_thread = QueueThread(self.stored_user_id)
self.queue_thread = QueueThread(self.stored_user_id, parent=self) # ← parent=self
self.queue_thread.update.connect(self.handle_update)
self.queue_thread.start()
# 🧪 TEMP - Simule une position en queue pour tester l'UI
self.handle_update("position:3:10")
def handle_update(self, message: str):
# print(f"[handle_update] reçu: {message}") # ← Debug
if message == "ok":
self.ui.queue_lbl.setVisible(False)
self.ui.queue_position.setVisible(False)
#self.ui.connexion_btn.setEnabled(True)
#self.ui.connexion_btn.setText("Lancer FiveM")
#self.launch_fivem()
elif message == "ready":
self.ui.queue_lbl.setVisible(True)
self.ui.queue_position.setVisible(False)
self.ui.queue_lbl.setText("🚀 C'est votre tour !")
self.launch_fivem()
elif message.startswith("position:"):
_, pos, total = message.split(":")
self.ui.queue_lbl.setVisible(True)
self.ui.queue_position.setVisible(True)
#self.ui.queue_position.setText(f"{pos} / {total}") <- si on veux le total de slots
self.ui.queue_position.setText(f"{pos}")
def launch_fivem(self):
pass
class QueueThread(QThread):
update = Signal(str)
def __init__(self, user_id: str, parent=None): # ← parent=None
super().__init__(parent) # ← passé à QThread
self.manager = QueueManager(
user_id=user_id,
on_update=self.update.emit
)
def run(self):
self.manager.start()
def stop(self):
self.manager.stop()

View File

@@ -15,19 +15,17 @@ QFrame#logo_frame {
background-position: center;
}
QFrame#frame_2 QLabel {
QFrame#frame_2 QLabel,
QFrame#frame_6 QLabel {
color: rgb(163, 177, 198);
}
QFrame#frame_2 QLabel#discord_title_label {
QFrame#frame_2 QLabel#discord_info_title_label,
QFrame#frame_6 QLabel#whitelist_info_title_label {
font-size: 28px;
color: rgb(255, 255, 255) /* label enfant, obligé de définir la couleur car ne prend pas la général */
}
QFrame#frame_2 QLabel#label_2 {
/* font-family: 'sans serif'; */
}
QLabel#maintitle_label {
font-size: 38px;
}
@@ -45,7 +43,8 @@ QLabel#queue_lbl {
}
QPushButton#connexion_btn {
/* ------------------------------------ BUTTONS --------------------------------------------------*/
QPushButton#connexion_btn[en_chantier="false"] {
/* Dégradé chaleureux : Orange vers Orange-Rouge */
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop: 0 #ff9d00,
@@ -57,7 +56,7 @@ QPushButton#connexion_btn {
padding: 10px;
}
QPushButton#connexion_btn:hover {
QPushButton#connexion_btn[en_chantier="false"]:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop: 0 #ffb338,
stop: 1 #ff7a29);
@@ -66,7 +65,7 @@ QPushButton#connexion_btn:hover {
outline: none;
}
QPushButton#connexionBtn:pressed {
QPushButton#connexion_btn[en_chantier="false"]:pressed {
background: #cc5200;
padding-top: 12px; /* Effet d'enfoncement */
}
@@ -74,29 +73,37 @@ QPushButton#connexionBtn:pressed {
/* État normal - Rouge Corail Vibrant */
QPushButton#staff_btn {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #FF4B2B, stop:1 #FF416C);
color: white;
stop: 0 #FF4B2B,
stop: 1 #FF416C);
border-radius: 12px;
border: 1px solid #d03522;
padding: 5px 15px;
color: white;
/* padding: 5px 15px;*/
padding: 10px;
}
QPushButton#staff_btn:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #FF6046, stop:1 #FF527B);
border: 1px solid #FF4B2B;
stop: 0 #FF6046,
stop: 1 #FF527B);
/* border: 1px solid #FF4B2B;*/
border: 1px solid #FFFFFF;
/* Un léger halo autour du bouton */
outline: none;
}
QPushButton#staff_btn:pressed
{
background-color: #d03522;
padding-top: 7px;
padding-left: 17px;
/* padding-top: 7px;*/
/* padding-left: 17px;*/
padding: 12px;
}
QPushButton#discord_btn,
QPushButton#discord_auth_btn,
QPushButton#intranet_btn
QPushButton#intranet_btn,
QPushButton#no_whitelist_btn
{
background-color: rgba(32, 58, 67, 0.6); /* Bleu très sombre semi-transparent */
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -107,7 +114,8 @@ QPushButton#intranet_btn
}
QPushButton#discord_btn:hover,
QPushButton#discord_auth_btn:hover
QPushButton#discord_auth_btn:hover,
QPushButton#no_whitelist_btn:hover
{
background-color: rgba(88, 101, 242, 0.4); /* Fond bleu Discord translucide */
border: 2px solid #7289da; /* Bordure plus épaisse et claire pour l'éclat */
@@ -156,6 +164,23 @@ QPushButton#minimize_btn {
padding-top: 0
}
HazardButton#connexion_btn {
color: #0A1A3A;
/* color: #0D2A6B;*/
font-weight: bold;
}
HazardButton#connexion_btn:hover {
/* color: #ffffff;*/
color: #0D2A6B;
}
HazardButton#connexion_btn:pressed {
color: #333333;
}
/* ------------------------------------ Other --------------------------------------------------*/
QFrame#info_frame{
background: qlineargradient(
x1:0, y1:0,
@@ -209,3 +234,92 @@ QSlider::handle:horizontal:pressed {
background: #E65100;
border-radius: 8px;
}
/* ----------------------------------------------
Custom Message Box
----------------------------------------------*/
QWidget#MsgBoxMainContainer {
border-radius: 15px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
QWidget#MsgBoxMainContainer[iconType="info"] {
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 #101624,
stop: 1 #248277
);
}
QWidget#MsgBoxMainContainer[iconType="warning"] {
background: qlineargradient(
x1: 0, y1: 0, x2: 1, y2: 1,
stop: 0 #101624,
stop: 1 #cf5b16
);
}
QWidget#MsgBoxMainContainer QLabel,
QWidget#MsgBoxMainContainer QPushButton
{
color: white;
/*font-size: 18px;
/* font-family: 'Segoe UI';*/
}
QPushButton#MsgBoxOkButton,
QPushButton#MsgBoxCancelButton {
border-radius: 6px;
/* color: white;*/
padding: 8px 20px;
font-weight: bold;
font-size: 12px;
border: 1px solid rgba(255,255,255,0.05);
}
QPushButton#MsgBoxOkButton {
background: #248277;
}
QPushButton#MsgBoxCancelButton {
background: #2a313d;
}
QPushButton#MsgBoxOkButton:hover,
QPushButton#MsgBoxCancelButton:hover{
background: #363d4a;
border: 1px solid white;
}
QPushButton#MsgBoxCloseButton {
background: transparent;
color: white;
border: none;
font-size: 14px;
/* Rayon identique au container (15px) pour épouser parfaitement le coin */
border-top-right-radius: 15px;
border-bottom-left-radius: 10px;
}
QPushButton#MsgBoxCloseButton:hover {
background-color: #e74c3c;
color: white;
}
QLabel#MsgBoxTitleLabel {
font-weight: bold;
font-size: 14px;
color: rgba(255,255,255,0.7);
letter-spacing: 1px;
}
QLabel#MsgBoxMessageLabel {
font-size: 14px;
color: #f0f0f0;
}
QLabel#MsgBoxIconLabel {
font-size: 35px;
margin-right: 10px;
}

View File

@@ -363,7 +363,7 @@
<item>
<widget class="QLabel" name="queue_position">
<property name="text">
<string>20</string>
<string>-</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
@@ -627,7 +627,7 @@
<property name="minimumSize">
<size>
<width>700</width>
<height>0</height>
<height>482</height>
</size>
</property>
<property name="maximumSize">
@@ -640,39 +640,39 @@
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="currentIndex">
<number>1</number>
<number>2</number>
</property>
<widget class="QWidget" name="page">
<widget class="QWidget" name="main_page">
<property name="minimumSize">
<size>
<width>0</width>
<height>650</height>
<height>482</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>700</width>
<height>650</height>
<height>482</height>
</size>
</property>
<widget class="QFrame" name="frame">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<width>680</width>
<height>650</height>
<x>0</x>
<y>0</y>
<width>700</width>
<height>482</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>650</height>
<height>0</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>680</width>
<width>700</width>
<height>650</height>
</size>
</property>
@@ -907,6 +907,22 @@
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QFrame" name="info_frame">
<property name="sizePolicy">
@@ -959,22 +975,6 @@
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_3">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>170</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</widget>
@@ -982,20 +982,20 @@
<property name="minimumSize">
<size>
<width>0</width>
<height>650</height>
<height>482</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>700</width>
<height>650</height>
<height>482</height>
</size>
</property>
<widget class="QFrame" name="frame_2">
<property name="geometry">
<rect>
<x>10</x>
<y>10</y>
<x>0</x>
<y>0</y>
<width>700</width>
<height>482</height>
</rect>
@@ -1078,7 +1078,7 @@
</spacer>
</item>
<item>
<widget class="QLabel" name="label">
<widget class="QLabel" name="discord_info_icon">
<property name="minimumSize">
<size>
<width>32</width>
@@ -1103,7 +1103,7 @@
</widget>
</item>
<item>
<widget class="QLabel" name="discord_title_label">
<widget class="QLabel" name="discord_info_title_label">
<property name="minimumSize">
<size>
<width>180</width>
@@ -1135,7 +1135,7 @@
</widget>
</item>
<item>
<widget class="QFrame" name="frame_7">
<widget class="QFrame" name="auth_discord_page_content">
<property name="maximumSize">
<size>
<width>700</width>
@@ -1322,6 +1322,357 @@
</layout>
</widget>
</widget>
<widget class="QWidget" name="whitelist">
<property name="minimumSize">
<size>
<width>700</width>
<height>482</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>700</width>
<height>482</height>
</size>
</property>
<widget class="QFrame" name="frame_6">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>700</width>
<height>482</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>700</width>
<height>482</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>700</width>
<height>482</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_8">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="QFrame" name="whitelist_page_title">
<property name="minimumSize">
<size>
<width>0</width>
<height>80</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>80</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_5">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="horizontalSpacer_9">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>292</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="whitelist_info_icon_labe">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap resource="../resources.qrc">:/assets/letter-i-info.svg</pixmap>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="whitelist_info_title_label">
<property name="minimumSize">
<size>
<width>180</width>
<height>30</height>
</size>
</property>
<property name="text">
<string>Information</string>
</property>
<property name="scaledContents">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_11">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>291</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<widget class="QFrame" name="frame_7">
<property name="maximumSize">
<size>
<width>700</width>
<height>16777215</height>
</size>
</property>
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QVBoxLayout" name="verticalLayout_7">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="verticalSpacer_6">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>0</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QLabel" name="whitelist_info_text">
<property name="minimumSize">
<size>
<width>0</width>
<height>50</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>50</height>
</size>
</property>
<property name="text">
<string>Tu nes pas whitelisté sur le serveur.
Inscris-toi sur Discord, puis relance le launcher.</string>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
</property>
</widget>
</item>
<item>
<spacer name="verticalSpacer_11">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>30</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QFrame" name="frame_9">
<property name="frameShape">
<enum>QFrame::Shape::NoFrame</enum>
</property>
<property name="frameShadow">
<enum>QFrame::Shadow::Raised</enum>
</property>
<layout class="QHBoxLayout" name="horizontalLayout_10">
<property name="spacing">
<number>0</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>0</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<spacer name="horizontalSpacer_12">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item>
<widget class="QPushButton" name="no_whitelist_btn">
<property name="minimumSize">
<size>
<width>380</width>
<height>50</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>250</width>
<height>50</height>
</size>
</property>
<property name="text">
<string> Compris !</string>
</property>
<property name="icon">
<iconset resource="../resources.qrc">
<normaloff>:/assets/no_whitelist.svg</normaloff>:/assets/no_whitelist.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
</widget>
</item>
<item>
<spacer name="horizontalSpacer_13">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="verticalSpacer_12">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Policy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>200</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</widget>
</widget>
</item>
</layout>