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'), ('.\\styles\\styles.qss', 'styles'),
('.\\ui\\mainwindow_vertical_pager.ui', 'ui') ('.\\ui\\mainwindow_vertical_pager.ui', 'ui')
], ],
hiddenimports=[], hiddenimports=[
"asyncio",
"pypresence",
"pypresence.baseclient",
],
hookspath=[], hookspath=[],
hooksconfig={ hooksconfig={
"qt_plugins": ["platforms", "styles"] "qt_plugins": ["platforms", "styles"]
@@ -107,8 +111,8 @@ a = Analysis(
# Concurrency non utilisée dans ton code # Concurrency non utilisée dans ton code
"multiprocessing", "multiprocessing",
"concurrent", #"concurrent",
"asyncio", #"asyncio",
# REPL / terminal # REPL / terminal
"readline", "readline",
@@ -218,6 +222,15 @@ a.binaries = [
if not any(u.lower() in name.lower() for u in unwanted_dlls) 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) pyz = PYZ(a.pure, a.zipped_data)
exe = EXE( 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. Note: in case if you aren't virtual environment, the script stop.
Enjoy. 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> <RCC>
<qresource prefix="/"> <qresource prefix="/">
<file>assets/no_whitelist.svg</file>
<file>assets/closed-store-info.svg</file> <file>assets/closed-store-info.svg</file>
<file>assets/letter-i-info.svg</file> <file>assets/letter-i-info.svg</file>
<file>assets/open-store-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 config.config_manager import ConfigManager, VOLUME_KEY
from tools.constants import Resources from config.constants import Resources
class AudioController: class AudioController:
# Encapsule toute la logique audio : lecture, volume, mute. # Encapsule toute la logique audio : lecture, volume, mute.

View File

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

View File

@@ -181151,6 +181151,119 @@ ill:#60C3AB;\x22 cx\
=\x22225.067\x22 cy=\x224\ =\x22225.067\x22 cy=\x224\
43.733\x22 r=\x2211.73\ 43.733\x22 r=\x2211.73\
3\x22/>\x0d\x0a</svg>\ 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"\ qt_resource_name = b"\
@@ -181217,12 +181330,16 @@ qt_resource_name = b"\
\x0cA\xab\xe7\ \x0cA\xab\xe7\
\x00c\ \x00c\
\x00o\x00m\x00p\x00u\x00t\x00e\x00r\x00-\x00t\x00v\x00.\x00s\x00v\x00g\ \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"\ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \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\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\x00\x00\x00\x00\x00\x00\
\x00\x00\x01\xba\x00\x00\x00\x00\x00\x01\x00+\xdd\xc1\ \x00\x00\x01\xba\x00\x00\x00\x00\x00\x01\x00+\xdd\xc1\
\x00\x00\x01\x9c\x0ft\xac\xa3\ \x00\x00\x01\x9c\x0ft\xac\xa3\
@@ -181240,6 +181357,8 @@ qt_resource_struct = b"\
\x00\x00\x01\x9c\xd8\xa1X\x0f\ \x00\x00\x01\x9c\xd8\xa1X\x0f\
\x00\x00\x00\x9c\x00\x00\x00\x00\x00\x01\x00(\xfb*\ \x00\x00\x00\x9c\x00\x00\x00\x00\x00\x01\x00(\xfb*\
\x00\x00\x01\x9c\xd9\xa2\xfa\xf9\ \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\x00\xb2\x00\x00\x00\x00\x00\x01\x00)\x83\x02\
\x00\x00\x01\x9c\xed\xb0@\xf2\ \x00\x00\x01\x9c\xed\xb0@\xf2\
\x00\x00\x01\xea\x00\x00\x00\x00\x00\x01\x00,1\x15\ \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 import sys
from pathlib import Path from pathlib import Path
from PySide6.QtWidgets import QApplication
PROJECT_ROOT = Path(__file__).resolve().parents[2] 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 # sys.executable est le chemin complet vers l'application .exe
return Path(sys.executable).parent.resolve() return Path(sys.executable).parent.resolve()
return Path(__file__).resolve().parents[2] 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, from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QPushButton, QWidget, QGraphicsDropShadowEffect) QLabel, QPushButton, QWidget, QGraphicsDropShadowEffect)
from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve from PySide6.QtCore import Qt, QPropertyAnimation, QEasingCurve
@@ -20,9 +19,6 @@ class CustomMessageBox(QDialog):
self.setAttribute(Qt.WA_TranslucentBackground) self.setAttribute(Qt.WA_TranslucentBackground)
self.setMinimumWidth(400) self.setMinimumWidth(400)
color_main = "#101624"
color_accent = "#248277" if icon_type == self.INFO else "#cf5b16"
# --- ANIMATION DE FONDU --- # --- ANIMATION DE FONDU ---
self.setWindowOpacity(0) self.setWindowOpacity(0)
self.fade_anim = QPropertyAnimation(self, b"windowOpacity") self.fade_anim = QPropertyAnimation(self, b"windowOpacity")
@@ -33,16 +29,10 @@ class CustomMessageBox(QDialog):
# --- UI SETUP --- # --- UI SETUP ---
self.container = QWidget(self) self.container = QWidget(self)
self.container.setObjectName("MainContainer") self.container.setObjectName("MsgBoxMainContainer")
self.container.setStyleSheet(f""" # Utilisé dans le fichier QSS comme condition dynamique de style
QWidget#MainContainer {{ self.container.setProperty("iconType", icon_type)
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, self.container.setProperty("buttonsType", buttons)
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'; }}
""")
# LAYOUT PRINCIPAL DU CONTAINER (Marges à 0 pour coller le bouton au bord) # LAYOUT PRINCIPAL DU CONTAINER (Marges à 0 pour coller le bouton au bord)
layout = QVBoxLayout(self.container) layout = QVBoxLayout(self.container)
@@ -55,28 +45,13 @@ class CustomMessageBox(QDialog):
title_bar_layout.setSpacing(0) title_bar_layout.setSpacing(0)
title_label = QLabel(title.upper()) title_label = QLabel(title.upper())
title_label.setStyleSheet( title_label.setObjectName("MsgBoxTitleLabel")
"font-weight: bold; font-size: 10px; color: rgba(255,255,255,0.7); letter-spacing: 1px;")
self.close_btn = QPushButton("") self.close_btn = QPushButton("")
self.close_btn.setObjectName("MsgBoxCloseButton")
self.close_btn.setFixedSize(45, 35) self.close_btn.setFixedSize(45, 35)
self.close_btn.clicked.connect(self.reject) self.close_btn.clicked.connect(self.reject)
self.close_btn.setCursor(Qt.PointingHandCursor) 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.addWidget(title_label)
title_bar_layout.addStretch() title_bar_layout.addStretch()
@@ -91,12 +66,12 @@ class CustomMessageBox(QDialog):
# Contenu central (Icône + Message) # Contenu central (Icône + Message)
content_layout = QHBoxLayout() content_layout = QHBoxLayout()
icon_label = QLabel() icon_label = QLabel()
icon_label.setObjectName("MsgBoxIconLabel")
icon_text = "" if icon_type == self.INFO else "⚠️" icon_text = "" if icon_type == self.INFO else "⚠️"
icon_label.setText(icon_text) icon_label.setText(icon_text)
icon_label.setStyleSheet("font-size: 35px; margin-right: 10px;")
msg_label = QLabel(message) msg_label = QLabel(message)
msg_label.setStyleSheet("font-size: 14px; color: #f0f0f0;") msg_label.setObjectName("MsgBoxMessageLabel")
msg_label.setWordWrap(True) msg_label.setWordWrap(True)
content_layout.addWidget(icon_label) content_layout.addWidget(icon_label)
@@ -108,24 +83,14 @@ class CustomMessageBox(QDialog):
btn_layout.setSpacing(10) btn_layout.setSpacing(10)
btn_layout.addStretch() 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: if buttons == self.OK_CANCEL:
self.btn_cancel = QPushButton("ANNULER") self.btn_cancel = QPushButton("ANNULER")
self.btn_cancel.setStyleSheet(style_btn_base) self.btn_cancel.setObjectName("MsgBoxCancelButton")
self.btn_cancel.clicked.connect(self.reject) self.btn_cancel.clicked.connect(self.reject)
btn_layout.addWidget(self.btn_cancel) btn_layout.addWidget(self.btn_cancel)
self.btn_ok = QPushButton("COMPRIS") self.btn_ok = QPushButton("COMPRIS")
style_btn_ok = style_btn_base.replace("#2a313d", "#248277") self.btn_ok.setObjectName("MsgBoxOkButton")
self.btn_ok.setStyleSheet(style_btn_ok)
self.btn_ok.setCursor(Qt.PointingHandCursor) self.btn_ok.setCursor(Qt.PointingHandCursor)
self.btn_ok.clicked.connect(self.accept) self.btn_ok.clicked.connect(self.accept)
btn_layout.addWidget(self.btn_ok) 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.QtCore import Qt
from PySide6.QtUiTools import QUiLoader from PySide6.QtUiTools import QUiLoader
from PySide6.QtWidgets import QMainWindow, QSizePolicy from PySide6.QtWidgets import QMainWindow, QSizePolicy
from PySide6.QtCore import QThread, Signal
from config.config_manager import ConfigManager 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.audio_controller import AudioController
from controllers.glow_animator import GlowAnimator from controllers.glow_animator import GlowAnimator
from controllers.window_dragger import WindowDragger 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 from fake_patch_notes import patch_note
# For Linux Wayland to authorize moving window # For Linux Wayland to authorize moving window
@@ -19,10 +28,14 @@ if platform.startswith('linux'):
environ["QT_QPA_PLATFORM"] = "xcb" environ["QT_QPA_PLATFORM"] = "xcb"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
#update = Signal(str) # Reçoit les callbacks de QueueManager
def __init__(self, bundle_dir: str, config_manager: ConfigManager): def __init__(self, bundle_dir: str, config_manager: ConfigManager):
super().__init__() super().__init__()
self.config = config_manager self.config = config_manager
self.stored_user_id = self.config.get_discord_user()
self.queue_thread = None
# UI # UI
self.ui = QUiLoader().load(f"{bundle_dir}/ui/mainwindow_vertical_pager.ui", self) 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.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 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 # centrage vertical du bouton connexion
if NO_STAFF: if not PlayerServerInfo.is_staff:
self.ui.staff_btn.hide() self.ui.staff_btn.hide()
layout = self.ui.verticalLayout_6 layout = self.ui.verticalLayout_6
# Trouver et modifier le spacer item # Trouver et modifier le spacer item
@@ -41,12 +106,6 @@ class MainWindow(QMainWindow):
item.spacerItem().changeSize(20, 15, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) item.spacerItem().changeSize(20, 15, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
layout.invalidate() # Forcer le recalcul du layout layout.invalidate() # Forcer le recalcul du layout
break 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) self.ui.info_text.setMarkdown(patch_note)
@@ -57,8 +116,10 @@ class MainWindow(QMainWindow):
self._connect_signals() self._connect_signals()
self._center_window() self._center_window()
self.show() self.show()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Setup # Setup
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -69,8 +130,8 @@ class MainWindow(QMainWindow):
self.ui.connexion_btn.clicked.connect(self._on_connexion) self.ui.connexion_btn.clicked.connect(self._on_connexion)
self.ui.discord_btn.clicked.connect(self._on_discord) self.ui.discord_btn.clicked.connect(self._on_discord)
self.ui.intranet_btn.clicked.connect(self._on_intranet) self.ui.intranet_btn.clicked.connect(self._on_intranet)
self.ui.discord_auth_btn.clicked.connect(self._on_discord_auth_btn) 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: def _center_window(self) -> None:
self.adjustSize() self.adjustSize()
@@ -87,7 +148,12 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_connexion(self) -> None: 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 @staticmethod
def _on_discord() -> None: def _on_discord() -> None:
@@ -98,8 +164,14 @@ class MainWindow(QMainWindow):
self._glow.start() self._glow.start()
def _on_discord_auth_btn(self) -> None: 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 # Mouse events → délégués au WindowDragger
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -121,5 +193,70 @@ class MainWindow(QMainWindow):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def closeEvent(self, event) -> None: 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() self.config.save()
super().closeEvent(event) 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; background-position: center;
} }
QFrame#frame_2 QLabel { QFrame#frame_2 QLabel,
QFrame#frame_6 QLabel {
color: rgb(163, 177, 198); 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; font-size: 28px;
color: rgb(255, 255, 255) /* label enfant, obligé de définir la couleur car ne prend pas la général */ 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 { QLabel#maintitle_label {
font-size: 38px; 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 */ /* Dégradé chaleureux : Orange vers Orange-Rouge */
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop: 0 #ff9d00, stop: 0 #ff9d00,
@@ -57,7 +56,7 @@ QPushButton#connexion_btn {
padding: 10px; padding: 10px;
} }
QPushButton#connexion_btn:hover { QPushButton#connexion_btn[en_chantier="false"]:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop: 0 #ffb338, stop: 0 #ffb338,
stop: 1 #ff7a29); stop: 1 #ff7a29);
@@ -66,7 +65,7 @@ QPushButton#connexion_btn:hover {
outline: none; outline: none;
} }
QPushButton#connexionBtn:pressed { QPushButton#connexion_btn[en_chantier="false"]:pressed {
background: #cc5200; background: #cc5200;
padding-top: 12px; /* Effet d'enfoncement */ padding-top: 12px; /* Effet d'enfoncement */
} }
@@ -74,29 +73,37 @@ QPushButton#connexionBtn:pressed {
/* État normal - Rouge Corail Vibrant */ /* État normal - Rouge Corail Vibrant */
QPushButton#staff_btn { QPushButton#staff_btn {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #FF4B2B, stop:1 #FF416C); stop: 0 #FF4B2B,
color: white; stop: 1 #FF416C);
border-radius: 12px; border-radius: 12px;
border: 1px solid #d03522; border: 1px solid #d03522;
padding: 5px 15px; color: white;
/* padding: 5px 15px;*/
padding: 10px;
} }
QPushButton#staff_btn:hover { QPushButton#staff_btn:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 #FF6046, stop:1 #FF527B); stop: 0 #FF6046,
border: 1px solid #FF4B2B; stop: 1 #FF527B);
/* border: 1px solid #FF4B2B;*/
border: 1px solid #FFFFFF;
/* Un léger halo autour du bouton */
outline: none;
} }
QPushButton#staff_btn:pressed QPushButton#staff_btn:pressed
{ {
background-color: #d03522; background-color: #d03522;
padding-top: 7px; /* padding-top: 7px;*/
padding-left: 17px; /* padding-left: 17px;*/
padding: 12px;
} }
QPushButton#discord_btn, QPushButton#discord_btn,
QPushButton#discord_auth_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 */ background-color: rgba(32, 58, 67, 0.6); /* Bleu très sombre semi-transparent */
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -107,7 +114,8 @@ QPushButton#intranet_btn
} }
QPushButton#discord_btn:hover, 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 */ background-color: rgba(88, 101, 242, 0.4); /* Fond bleu Discord translucide */
border: 2px solid #7289da; /* Bordure plus épaisse et claire pour l'éclat */ border: 2px solid #7289da; /* Bordure plus épaisse et claire pour l'éclat */
@@ -156,6 +164,23 @@ QPushButton#minimize_btn {
padding-top: 0 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{ QFrame#info_frame{
background: qlineargradient( background: qlineargradient(
x1:0, y1:0, x1:0, y1:0,
@@ -209,3 +234,92 @@ QSlider::handle:horizontal:pressed {
background: #E65100; background: #E65100;
border-radius: 8px; 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> <item>
<widget class="QLabel" name="queue_position"> <widget class="QLabel" name="queue_position">
<property name="text"> <property name="text">
<string>20</string> <string>-</string>
</property> </property>
<property name="alignment"> <property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set> <set>Qt::AlignmentFlag::AlignCenter</set>
@@ -627,7 +627,7 @@
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>700</width> <width>700</width>
<height>0</height> <height>482</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
@@ -640,39 +640,39 @@
<enum>QFrame::Shape::NoFrame</enum> <enum>QFrame::Shape::NoFrame</enum>
</property> </property>
<property name="currentIndex"> <property name="currentIndex">
<number>1</number> <number>2</number>
</property> </property>
<widget class="QWidget" name="page"> <widget class="QWidget" name="main_page">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
<height>650</height> <height>482</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>700</width> <width>700</width>
<height>650</height> <height>482</height>
</size> </size>
</property> </property>
<widget class="QFrame" name="frame"> <widget class="QFrame" name="frame">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>10</x> <x>0</x>
<y>10</y> <y>0</y>
<width>680</width> <width>700</width>
<height>650</height> <height>482</height>
</rect> </rect>
</property> </property>
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
<height>650</height> <height>0</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>680</width> <width>700</width>
<height>650</height> <height>650</height>
</size> </size>
</property> </property>
@@ -907,6 +907,22 @@
</layout> </layout>
</widget> </widget>
</item> </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> <item>
<widget class="QFrame" name="info_frame"> <widget class="QFrame" name="info_frame">
<property name="sizePolicy"> <property name="sizePolicy">
@@ -959,22 +975,6 @@
</layout> </layout>
</widget> </widget>
</item> </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> </layout>
</widget> </widget>
</widget> </widget>
@@ -982,20 +982,20 @@
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>0</width> <width>0</width>
<height>650</height> <height>482</height>
</size> </size>
</property> </property>
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>700</width> <width>700</width>
<height>650</height> <height>482</height>
</size> </size>
</property> </property>
<widget class="QFrame" name="frame_2"> <widget class="QFrame" name="frame_2">
<property name="geometry"> <property name="geometry">
<rect> <rect>
<x>10</x> <x>0</x>
<y>10</y> <y>0</y>
<width>700</width> <width>700</width>
<height>482</height> <height>482</height>
</rect> </rect>
@@ -1078,7 +1078,7 @@
</spacer> </spacer>
</item> </item>
<item> <item>
<widget class="QLabel" name="label"> <widget class="QLabel" name="discord_info_icon">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>32</width> <width>32</width>
@@ -1103,7 +1103,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QLabel" name="discord_title_label"> <widget class="QLabel" name="discord_info_title_label">
<property name="minimumSize"> <property name="minimumSize">
<size> <size>
<width>180</width> <width>180</width>
@@ -1135,7 +1135,7 @@
</widget> </widget>
</item> </item>
<item> <item>
<widget class="QFrame" name="frame_7"> <widget class="QFrame" name="auth_discord_page_content">
<property name="maximumSize"> <property name="maximumSize">
<size> <size>
<width>700</width> <width>700</width>
@@ -1322,6 +1322,357 @@
</layout> </layout>
</widget> </widget>
</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> </widget>
</item> </item>
</layout> </layout>