ini
This commit is contained in:
commit
0287dca42f
125
lib/constants.py
Normal file
125
lib/constants.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
class AppGUID:
|
||||||
|
Release = b"\x10\x5e\x62\xa7\x96\x1a\xd2\x11\x9a\xfc\x00\x60\x08\x45\xe5\x71"
|
||||||
|
Beta = b'j\xfbF/Le\xedJ\x8c\x0b\x06@\x14f\x9b\xfd'
|
||||||
|
|
||||||
|
class MapID:
|
||||||
|
ID = {
|
||||||
|
"MvM_L1": 19,
|
||||||
|
"MvM_L2": 20,
|
||||||
|
"MvM_L3": 21,
|
||||||
|
"RvR_L1": 22,
|
||||||
|
"RvR_L2": 23,
|
||||||
|
"RvR_L3": 24,
|
||||||
|
"3W_L1": 25,
|
||||||
|
"3W_L2": 26,
|
||||||
|
"3W_L3": 27,
|
||||||
|
"MvM_LW1": 28,
|
||||||
|
"RvR_LW1": 29,
|
||||||
|
"3W_LW1": 30,
|
||||||
|
"MvM_L6": 31,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_id(name):
|
||||||
|
if name in MapID.ID:
|
||||||
|
return MapID.ID[name]
|
||||||
|
else:
|
||||||
|
return 0xFF
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_name(map_id):
|
||||||
|
for _map_name in MapID.ID:
|
||||||
|
_map_id = MapID.ID[_map_name]
|
||||||
|
if _map_id == map_id:
|
||||||
|
return _map_name
|
||||||
|
return None
|
||||||
|
|
||||||
|
class Teams:
|
||||||
|
@staticmethod
|
||||||
|
def get_name_by_id(team_id):
|
||||||
|
for prop in Teams.__dict__:
|
||||||
|
if Teams.__dict__[prop] == team_id:
|
||||||
|
return prop
|
||||||
|
return "Unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_groupid_by_team_id(_id, teams):
|
||||||
|
print("get_groupid_by_team_id: %s %s" % (_id, teams))
|
||||||
|
if teams == Teams.MvK:
|
||||||
|
if _id == 1:
|
||||||
|
return 2
|
||||||
|
if _id == 2:
|
||||||
|
return 1
|
||||||
|
if teams == Teams.MvMvM or teams == Teams.MvM:
|
||||||
|
return 2
|
||||||
|
if teams == Teams.RvR:
|
||||||
|
return 3
|
||||||
|
if teams == Teams.MvR:
|
||||||
|
if _id == 1:
|
||||||
|
return 2
|
||||||
|
if _id == 2:
|
||||||
|
return 3
|
||||||
|
if teams == Teams.MvRvK:
|
||||||
|
if _id == 1:
|
||||||
|
return 2
|
||||||
|
if _id == 2:
|
||||||
|
return 3
|
||||||
|
if _id == 3:
|
||||||
|
return 1
|
||||||
|
if teams == Teams.RvK:
|
||||||
|
if _id == 1:
|
||||||
|
return 3
|
||||||
|
if _id == 2:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
MvM = 0x00
|
||||||
|
MvMvM = 0x01
|
||||||
|
RvR = 0x02
|
||||||
|
MvR = 0x03
|
||||||
|
MvRvK = 0x04
|
||||||
|
MvK = 0x05
|
||||||
|
RvK = 0x06
|
||||||
|
TeamB = 0x07
|
||||||
|
TeamB = 0x08
|
||||||
|
TeamB = 0x0c
|
||||||
|
TeamB = 0x10
|
||||||
|
|
||||||
|
class GameTypes:
|
||||||
|
@staticmethod
|
||||||
|
def get_name_by_id(gametype_id):
|
||||||
|
for prop in GameTypes.__dict__:
|
||||||
|
if GameTypes.__dict__[prop] == gametype_id:
|
||||||
|
return prop
|
||||||
|
return "Unknown"
|
||||||
|
# 00: Team Deathmatch
|
||||||
|
# 01: Team Deathmatch with full base
|
||||||
|
# 02: Capture Smartie
|
||||||
|
# 03: Capture Smartie with full base
|
||||||
|
# 04: Base Build Deathmatch
|
||||||
|
# 05: Base Build and Capture the Smartie
|
||||||
|
# 06: Defend Base
|
||||||
|
# 07: Defend Base and Capture the Smartie
|
||||||
|
# 08: GTypeStone
|
||||||
|
# 09: GTypeWood
|
||||||
|
# 0a: crash to desktop
|
||||||
|
# 0b: crash to desktop
|
||||||
|
# 0c: GType(null)
|
||||||
|
# 0d: crash to desktop
|
||||||
|
# 0e: crash to desktop
|
||||||
|
# 0f: crash to desktop
|
||||||
|
# 10: crash to desktop
|
||||||
|
# aa: crash to desktop
|
||||||
|
# ff: crash to desktop
|
||||||
|
|
||||||
|
TeamDeathmatch = 0x00
|
||||||
|
TeamDeathmatchWithFullBase = 0x01
|
||||||
|
CaptureSmartie = 0x02
|
||||||
|
CaptureSmartieWithFullBase = 0x03
|
||||||
|
BaseBuildDeathmatch = 0x04
|
||||||
|
BaseBuildCaptureSmartie = 0x05
|
||||||
|
DefendBase = 0x06
|
||||||
|
DefendBaseCaptureSmartie = 0x07
|
||||||
|
GTypeStone = 0x08
|
||||||
|
GTypeWood = 0x09
|
||||||
|
Crash = 0x0a
|
||||||
|
GTypeNull = 0x0c
|
47
lib/enumquery.py
Normal file
47
lib/enumquery.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from lib.packet import Packet
|
||||||
|
import random
|
||||||
|
|
||||||
|
|
||||||
|
class EnumQuery:
|
||||||
|
LEAD = 0x00
|
||||||
|
COMMAND = 0x02
|
||||||
|
NO_APPLICATION_GUID = 0x02
|
||||||
|
HAS_APPLICATION_GUID = 0x01
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.Lead = EnumQuery.LEAD
|
||||||
|
self.Command = EnumQuery.COMMAND
|
||||||
|
self.Payload = random.getrandbits(16)
|
||||||
|
self.Type = None
|
||||||
|
self.ApplicationGUID = None
|
||||||
|
self.ApplicationPayload = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, packet: Packet):
|
||||||
|
enumquery = cls()
|
||||||
|
packet.seek(0)
|
||||||
|
enumquery.Lead = packet.get_byte()
|
||||||
|
enumquery.Command = packet.get_byte()
|
||||||
|
enumquery.Payload = packet.get_short()
|
||||||
|
enumquery.Type = packet.get_byte()
|
||||||
|
if enumquery.Type == EnumQuery.HAS_APPLICATION_GUID:
|
||||||
|
enumquery.ApplicationGUID = packet.get_bytes(16)
|
||||||
|
enumquery.ApplicationPayload = packet.read()
|
||||||
|
elif enumquery.Type == EnumQuery.NO_APPLICATION_GUID:
|
||||||
|
enumquery.ApplicationPayload = packet.read()
|
||||||
|
return enumquery
|
||||||
|
|
||||||
|
def to_packet(self):
|
||||||
|
packet = Packet()
|
||||||
|
packet.put_byte(EnumQuery.LEAD)
|
||||||
|
packet.put_byte(EnumQuery.COMMAND)
|
||||||
|
packet.put_short(self.Payload)
|
||||||
|
if self.ApplicationGUID:
|
||||||
|
packet.put_byte(EnumQuery.HAS_APPLICATION_GUID)
|
||||||
|
packet.put_bytes(self.ApplicationGUID)
|
||||||
|
else:
|
||||||
|
packet.put_byte(EnumQuery.NO_APPLICATION_GUID)
|
||||||
|
|
||||||
|
if self.ApplicationPayload:
|
||||||
|
packet.put_bytes(self.ApplicationPayload)
|
||||||
|
return packet
|
117
lib/enumresponse.py
Normal file
117
lib/enumresponse.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
from lib.packet import Packet
|
||||||
|
|
||||||
|
|
||||||
|
class EnumResponse:
|
||||||
|
LEAD = 0x00
|
||||||
|
COMMAND = 0x03
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.Lead = EnumResponse.LEAD
|
||||||
|
self.Command = EnumResponse.COMMAND
|
||||||
|
self.Payload = b''
|
||||||
|
self.ReplyOffset = b''
|
||||||
|
self.ReplySize = b''
|
||||||
|
self.ApplicationDescSize = b''
|
||||||
|
self.ApplicationDescFlags = b''
|
||||||
|
self.MaxPlayers = b''
|
||||||
|
self.CurrentPlayers = b''
|
||||||
|
self.SessionNameOffset = b''
|
||||||
|
self.SessionNameSize = b''
|
||||||
|
self.PasswordOffset = b''
|
||||||
|
self.PasswordSize = b''
|
||||||
|
self.ReservedDataOffset = b''
|
||||||
|
self.ReservedDataSize = b''
|
||||||
|
self.ApplicationReservedDataOffset = b''
|
||||||
|
self.ApplicationReservedDataSize = b''
|
||||||
|
self.ApplicationInstanceGUID = b''
|
||||||
|
self.ApplicationGUID = b''
|
||||||
|
self.SessionName = ""
|
||||||
|
self.Password = b''
|
||||||
|
self.ReservedData = b''
|
||||||
|
self.ApplicationReservedData = b''
|
||||||
|
self.ApplicationData = b''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, packet: Packet):
|
||||||
|
enumresponse = cls()
|
||||||
|
packet.seek(0)
|
||||||
|
enumresponse.Lead = packet.get_byte()
|
||||||
|
if enumresponse.Lead != 0xcf:
|
||||||
|
enumresponse.Command = packet.get_byte()
|
||||||
|
enumresponse.Payload = packet.get_short()
|
||||||
|
enumresponse.ReplyOffset = packet.get_ulong()
|
||||||
|
enumresponse.ReplySize = packet.get_ulong()
|
||||||
|
else:
|
||||||
|
packet.get_3bytes_int() # cause we were supposed to read a full int
|
||||||
|
|
||||||
|
enumresponse.ApplicationDescSize = packet.get_ulong()
|
||||||
|
enumresponse.ApplicationDescFlags = packet.get_ulong()
|
||||||
|
enumresponse.MaxPlayers = packet.get_ulong()
|
||||||
|
enumresponse.CurrentPlayers = packet.get_ulong()
|
||||||
|
enumresponse.SessionNameOffset = packet.get_ulong()
|
||||||
|
enumresponse.SessionNameSize = packet.get_ulong()
|
||||||
|
enumresponse.PasswordOffset = packet.get_ulong()
|
||||||
|
enumresponse.PasswordSize = packet.get_ulong()
|
||||||
|
enumresponse.ReservedDataOffset = packet.get_ulong()
|
||||||
|
enumresponse.ReservedDataSize = packet.get_ulong()
|
||||||
|
enumresponse.ApplicationReservedDataOffset = packet.get_ulong()
|
||||||
|
enumresponse.ApplicationReservedDataSize = packet.get_ulong()
|
||||||
|
enumresponse.ApplicationInstanceGUID = packet.get_bytes(16)
|
||||||
|
enumresponse.ApplicationGUID = packet.get_bytes(16)
|
||||||
|
enumresponse.SessionName = packet.getvalue()[enumresponse.SessionNameOffset+4:enumresponse.SessionNameOffset+enumresponse.SessionNameSize+1].decode("utf8").replace("\x00", "")
|
||||||
|
enumresponse.ApplicationReservedData = packet.getvalue()[enumresponse.ApplicationReservedDataOffset+4:enumresponse.ApplicationReservedDataOffset+enumresponse.ApplicationReservedDataSize+4]
|
||||||
|
return enumresponse
|
||||||
|
|
||||||
|
def to_packet(self):
|
||||||
|
varpos = 88
|
||||||
|
self.SessionNameSize = len(self.SessionName.encode("utf-16"))
|
||||||
|
self.SessionNameOffset = varpos if self.SessionName else 0
|
||||||
|
varpos += self.SessionNameSize if self.SessionName else 0
|
||||||
|
|
||||||
|
self.PasswordSize = len(self.Password)
|
||||||
|
self.PasswordOffset = varpos if self.Password else 0
|
||||||
|
varpos += self.PasswordSize if self.Password else 0
|
||||||
|
|
||||||
|
self.ReservedDataSize = len(self.ReservedData)
|
||||||
|
self.ReservedDataOffset = varpos if self.ReservedData else 0
|
||||||
|
varpos += self.ReservedDataSize if self.ReservedData else 0
|
||||||
|
|
||||||
|
self.ApplicationReservedDataSize = len(self.ApplicationReservedData)
|
||||||
|
self.ApplicationReservedDataOffset = varpos if self.ApplicationReservedData else 0
|
||||||
|
varpos += self.ApplicationReservedDataSize if self.ApplicationReservedData else 0
|
||||||
|
|
||||||
|
self.ReplySize = len(self.ApplicationData)
|
||||||
|
self.ReplyOffset = varpos if self.ApplicationData else 0
|
||||||
|
varpos += self.ReplySize if self.ApplicationData else 0
|
||||||
|
|
||||||
|
packet = Packet()
|
||||||
|
packet.put_byte(EnumResponse.LEAD)
|
||||||
|
packet.put_byte(EnumResponse.COMMAND)
|
||||||
|
packet.put_short(self.Payload)
|
||||||
|
packet.put_ulong(self.ReplyOffset)
|
||||||
|
packet.put_ulong(self.ReplySize)
|
||||||
|
packet.put_ulong(self.ApplicationDescSize)
|
||||||
|
packet.put_ulong(self.ApplicationDescFlags)
|
||||||
|
packet.put_ulong(self.MaxPlayers)
|
||||||
|
packet.put_ulong(self.CurrentPlayers)
|
||||||
|
packet.put_ulong(self.SessionNameOffset)
|
||||||
|
packet.put_ulong(self.SessionNameSize)
|
||||||
|
packet.put_ulong(self.PasswordOffset)
|
||||||
|
packet.put_ulong(self.PasswordSize)
|
||||||
|
packet.put_ulong(self.ReservedDataOffset)
|
||||||
|
packet.put_ulong(self.ReservedDataSize)
|
||||||
|
packet.put_ulong(self.ApplicationReservedDataOffset)
|
||||||
|
packet.put_ulong(self.ApplicationReservedDataSize)
|
||||||
|
packet.put_bytes(self.ApplicationInstanceGUID)
|
||||||
|
packet.put_bytes(self.ApplicationGUID)
|
||||||
|
if self.SessionName:
|
||||||
|
packet.put_bytes((self.SessionName+'\x00').encode("utf-16-le"))
|
||||||
|
if self.Password:
|
||||||
|
packet.put_bytes(self.Password)
|
||||||
|
if self.ReservedData:
|
||||||
|
packet.put_bytes(self.ReservedData)
|
||||||
|
if self.ApplicationReservedData:
|
||||||
|
packet.put_bytes(self.ApplicationReservedData)
|
||||||
|
if self.ApplicationData:
|
||||||
|
packet.put_bytes(self.ApplicationData)
|
||||||
|
return packet
|
89
lib/giantsenumresponseparser.py
Normal file
89
lib/giantsenumresponseparser.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
from lib.enumresponse import EnumResponse, Packet
|
||||||
|
from lib.constants import Teams, GameTypes, MapID
|
||||||
|
|
||||||
|
|
||||||
|
class GiantsEnumResponseParser:
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.server_name = None
|
||||||
|
|
||||||
|
self.max_players = None
|
||||||
|
self.current_players = None
|
||||||
|
|
||||||
|
self.game_type = None
|
||||||
|
self.teams = None
|
||||||
|
self.version = None
|
||||||
|
self.points_per_capture = None
|
||||||
|
self.points_per_kill = None
|
||||||
|
self.base_level = None
|
||||||
|
|
||||||
|
self.allow_joiners = None
|
||||||
|
self.damage_teammates = None
|
||||||
|
self.lock_teams = None
|
||||||
|
self.is_dedicated = None
|
||||||
|
self.vimps_disabled = None
|
||||||
|
self.weap_bits = None
|
||||||
|
self.high_bandwidth = None
|
||||||
|
self.no_voting = None
|
||||||
|
self.smartie_difficulty = None
|
||||||
|
self.vimp_difficulty = None
|
||||||
|
|
||||||
|
self.capture_prevent_count = None
|
||||||
|
self.current_cap_prev_time = None
|
||||||
|
|
||||||
|
self.map_id = None
|
||||||
|
self.map_checksum = None
|
||||||
|
self.map_name = None
|
||||||
|
self.build = None
|
||||||
|
self.revision = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, enumresp: EnumResponse):
|
||||||
|
g_enumresponse = cls()
|
||||||
|
g_enumresponse.max_players = enumresp.MaxPlayers
|
||||||
|
g_enumresponse.current_players = enumresp.CurrentPlayers
|
||||||
|
g_enumresponse.server_name = enumresp.SessionName
|
||||||
|
|
||||||
|
game_data = enumresp.ApplicationReservedData
|
||||||
|
# print(game_data)
|
||||||
|
p = Packet(game_data)
|
||||||
|
g_enumresponse.map_id = p.get_byte()
|
||||||
|
g_enumresponse.game_type = p.get_byte()
|
||||||
|
g_enumresponse.teams = p.get_byte()
|
||||||
|
g_enumresponse.base_level = p.get_byte()
|
||||||
|
g_enumresponse.version = p.get_short()
|
||||||
|
|
||||||
|
bitmask = p.get_short()
|
||||||
|
b = bin(bitmask)[2:].zfill(16)
|
||||||
|
g_enumresponse.allow_joiners = b[0]
|
||||||
|
g_enumresponse.damage_teammates = b[1]
|
||||||
|
g_enumresponse.lock_teams = b[2]
|
||||||
|
g_enumresponse.is_dedicated = b[3]
|
||||||
|
g_enumresponse.vimps_disabled = b[4]
|
||||||
|
g_enumresponse.weap_bits = b[5]
|
||||||
|
g_enumresponse.high_bandwidth = b[6]
|
||||||
|
g_enumresponse.no_voting = b[7]
|
||||||
|
|
||||||
|
g_enumresponse.smartie_difficulty = b[8:12]
|
||||||
|
g_enumresponse.vimp_difficulty = b[12:16]
|
||||||
|
|
||||||
|
g_enumresponse.points_per_capture = p.get_short()
|
||||||
|
g_enumresponse.points_per_kill = p.get_short()
|
||||||
|
g_enumresponse.capture_prevent_count = p.get_short()
|
||||||
|
g_enumresponse.current_cap_prev_time = p.get_short()
|
||||||
|
|
||||||
|
g_enumresponse.map_checksum = p.get_ulong()
|
||||||
|
g_enumresponse.map_name = p.get_string(32)
|
||||||
|
|
||||||
|
if g_enumresponse.version >= 1498:
|
||||||
|
g_enumresponse.build = p.get_byte()
|
||||||
|
g_enumresponse.revision = p.get_byte()
|
||||||
|
|
||||||
|
if map_name := MapID.get_name(g_enumresponse.map_id):
|
||||||
|
g_enumresponse.map_name = map_name
|
||||||
|
|
||||||
|
return g_enumresponse
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return "[%s] Players:%d/%d, Map: %s, Type: %s, Teams: %s, Version: %d, Build: %s, AllowJoiners: %s" % \
|
||||||
|
(self.server_name, self.current_players, self.max_players, self.map_name, GameTypes.get_name_by_id(self.game_type), Teams.get_name_by_id(self.teams), self.version, self.build, self.allow_joiners)
|
70
lib/packet.py
Normal file
70
lib/packet.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import io
|
||||||
|
import struct
|
||||||
|
|
||||||
|
|
||||||
|
class Packet(io.BytesIO):
|
||||||
|
def put_byte(self, val) -> None:
|
||||||
|
self.write(struct.pack('<B', val % 256))
|
||||||
|
|
||||||
|
def get_byte(self) -> int:
|
||||||
|
return struct.unpack('<B', self.read(1))[0]
|
||||||
|
|
||||||
|
def put_bytes(self, b) -> None:
|
||||||
|
for byte in b:
|
||||||
|
self.write(struct.pack('<B', byte))
|
||||||
|
|
||||||
|
def get_bytes(self, num) -> bytes:
|
||||||
|
return bytes(struct.unpack('<'+str(num)+'B', self.read(num)))
|
||||||
|
|
||||||
|
def put_short(self, val) -> None:
|
||||||
|
self.write(struct.pack('<H', val))
|
||||||
|
|
||||||
|
def get_short(self) -> int:
|
||||||
|
return struct.unpack('<H', self.read(2))[0]
|
||||||
|
|
||||||
|
def put_long(self, val) -> None:
|
||||||
|
self.write(struct.pack('<l', val))
|
||||||
|
|
||||||
|
def put_ulong(self, val) -> None:
|
||||||
|
self.write(struct.pack('<L', val))
|
||||||
|
|
||||||
|
def get_long(self) -> int:
|
||||||
|
return struct.unpack('<l', self.read(4))[0]
|
||||||
|
|
||||||
|
def get_ulong(self) -> int:
|
||||||
|
return struct.unpack('<L', self.read(4))[0]
|
||||||
|
|
||||||
|
def put_longlong(self, val) -> None:
|
||||||
|
self.write(struct.pack('<Q', val))
|
||||||
|
|
||||||
|
def get_longlong(self) -> int:
|
||||||
|
return struct.unpack('<Q', self.read(8))[0]
|
||||||
|
|
||||||
|
def put_float(self, val) -> None:
|
||||||
|
self.write(struct.pack('<f', val))
|
||||||
|
|
||||||
|
def get_float(self) -> float:
|
||||||
|
return struct.unpack('<f', self.read(4))[0]
|
||||||
|
|
||||||
|
def put_string(self, val) -> None:
|
||||||
|
self.write(val + b'\x00')
|
||||||
|
|
||||||
|
def put_string_size(self, val, size) -> None:
|
||||||
|
self.write(val.encode("utf8") + b"\x00" * (size - len(val)))
|
||||||
|
|
||||||
|
def get_string_until_none(self) -> str:
|
||||||
|
s = ""
|
||||||
|
c = self.get_byte()
|
||||||
|
while c != 0x00:
|
||||||
|
s += chr(c)
|
||||||
|
c = self.get_byte()
|
||||||
|
return s
|
||||||
|
|
||||||
|
def get_string(self, size) -> str:
|
||||||
|
return self.get_bytes(size).decode("utf8").replace("\x00", "")
|
||||||
|
|
||||||
|
def get_3bytes_int(self) -> int:
|
||||||
|
return struct.unpack("<L", self.read(3)+b"\x00")[0]
|
||||||
|
|
||||||
|
def put_3bytes_int(self, val) -> None:
|
||||||
|
self.write(struct.pack("<L", val)[:-1])
|
272
main.py
Normal file
272
main.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
import base64
|
||||||
|
import datetime
|
||||||
|
import os.path
|
||||||
|
import pathlib
|
||||||
|
import socket
|
||||||
|
import zlib
|
||||||
|
import sys
|
||||||
|
from PySide6.QtCore import QThread, QTimer, Signal
|
||||||
|
from PySide6.QtWidgets import QApplication, QMainWindow, QFileDialog
|
||||||
|
from lib.enumresponse import EnumResponse
|
||||||
|
from lib.giantsenumresponseparser import GiantsEnumResponseParser
|
||||||
|
from lib.packet import Packet
|
||||||
|
from maps_gui import Ui_MainWindow
|
||||||
|
import requests
|
||||||
|
from lib.enumquery import EnumQuery
|
||||||
|
from lib.constants import AppGUID
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
installed_maps = {}
|
||||||
|
API_BASE_URL = "https://gckmaps.hipstercat.fr"
|
||||||
|
SLEEP_TIME = 20 * 1000
|
||||||
|
CONFIG = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_ip():
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
# doesn't even have to be reachable
|
||||||
|
s.connect(('10.255.255.255', 1))
|
||||||
|
ip = s.getsockname()[0]
|
||||||
|
except:
|
||||||
|
ip = '127.0.0.1'
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
return ip
|
||||||
|
|
||||||
|
|
||||||
|
LOCAL_IP = get_ip()
|
||||||
|
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super(MainWindow, self).__init__()
|
||||||
|
self.ui = Ui_MainWindow()
|
||||||
|
self.ui.setupUi(self)
|
||||||
|
|
||||||
|
self.timer = QTimer(self)
|
||||||
|
self.timer.setInterval(SLEEP_TIME)
|
||||||
|
self.timer.timeout.connect(self.timer_timeout)
|
||||||
|
|
||||||
|
self.background_task = MapsBackground()
|
||||||
|
self.background_task.finished.connect(self.background_task_finished)
|
||||||
|
self.background_task.msg.connect(self.message_received)
|
||||||
|
|
||||||
|
if CONFIG["automatic_download"]:
|
||||||
|
self.ui.automaticMapDownloadCheckBox.setChecked(True)
|
||||||
|
self.timer.start()
|
||||||
|
self.log("Background task started")
|
||||||
|
else:
|
||||||
|
self.ui.automaticMapDownloadCheckBox.setChecked(False)
|
||||||
|
self.ui.automaticMapDownloadCheckBox.stateChanged.connect(self.checkboxchanged)
|
||||||
|
self.ui.pushButton.clicked.connect(self.btnclicked)
|
||||||
|
|
||||||
|
def timer_timeout(self):
|
||||||
|
self.run_background_task()
|
||||||
|
|
||||||
|
def run_background_task(self):
|
||||||
|
if not self.background_task.isRunning():
|
||||||
|
self.ui.pushButton.setEnabled(False)
|
||||||
|
self.background_task.start()
|
||||||
|
|
||||||
|
def background_task_finished(self):
|
||||||
|
self.ui.pushButton.setEnabled(True)
|
||||||
|
|
||||||
|
def checkboxchanged(self):
|
||||||
|
checked = self.ui.automaticMapDownloadCheckBox.isChecked()
|
||||||
|
if checked:
|
||||||
|
self.timer.start()
|
||||||
|
self.log("Background task started")
|
||||||
|
else:
|
||||||
|
self.timer.stop()
|
||||||
|
self.log("Background task stopped")
|
||||||
|
CONFIG["automatic_download"] = checked
|
||||||
|
save_configuration()
|
||||||
|
|
||||||
|
def btnclicked(self):
|
||||||
|
self.run_background_task()
|
||||||
|
|
||||||
|
def message_received(self, m):
|
||||||
|
self.log(m)
|
||||||
|
|
||||||
|
def log(self, message: str) -> None:
|
||||||
|
full_msg = f"[{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {message}"
|
||||||
|
self.ui.logEdit.appendPlainText(full_msg)
|
||||||
|
print(full_msg)
|
||||||
|
|
||||||
|
|
||||||
|
def get_maps_dir() -> pathlib.Path:
|
||||||
|
return get_giants_directory() / "Bin" / "Worlds"
|
||||||
|
|
||||||
|
|
||||||
|
def load_installed_maps() -> None:
|
||||||
|
maps_dir = get_maps_dir()
|
||||||
|
|
||||||
|
def map_crc(map_file):
|
||||||
|
prev = 0
|
||||||
|
with open(map_file, "rb") as f:
|
||||||
|
b = f.read()
|
||||||
|
prev = zlib.crc32(b, prev)
|
||||||
|
return prev & 0xffffffff
|
||||||
|
|
||||||
|
installed_maps.clear()
|
||||||
|
for map_path in os.listdir(maps_dir):
|
||||||
|
if map_path.endswith(".cache") or map_path.endswith(".gck"):
|
||||||
|
map_path_s = pathlib.Path(maps_dir / map_path)
|
||||||
|
crc = map_crc(map_path_s)
|
||||||
|
installed_maps[crc] = map_path_s
|
||||||
|
|
||||||
|
|
||||||
|
def config_file_path() -> pathlib.Path:
|
||||||
|
datadir = get_datadir() / "giants-maps-downloader"
|
||||||
|
config_file = datadir / "config.yml"
|
||||||
|
try:
|
||||||
|
datadir.mkdir(parents=True)
|
||||||
|
except FileExistsError:
|
||||||
|
pass
|
||||||
|
return config_file
|
||||||
|
|
||||||
|
|
||||||
|
def read_configuration():
|
||||||
|
config_file = config_file_path()
|
||||||
|
global CONFIG
|
||||||
|
if not os.path.exists(config_file):
|
||||||
|
# create it
|
||||||
|
giants_exe_path, _filename = QFileDialog.getOpenFileName(None, caption="Open Giants.exe", filter="Giants.exe")
|
||||||
|
giants_dir = os.path.dirname(giants_exe_path)
|
||||||
|
CONFIG = {"giants_directory": giants_dir, "automatic_download": True}
|
||||||
|
save_configuration()
|
||||||
|
|
||||||
|
print(f"Config file: {config_file}")
|
||||||
|
with open(config_file, "r") as fp:
|
||||||
|
CONFIG = yaml.load(fp, Loader=yaml.Loader)
|
||||||
|
|
||||||
|
|
||||||
|
def save_configuration():
|
||||||
|
config_file = config_file_path()
|
||||||
|
with open(config_file, "w") as fp:
|
||||||
|
yaml.dump(CONFIG, fp)
|
||||||
|
print(f"Wrote configuration file to {config_file}")
|
||||||
|
|
||||||
|
|
||||||
|
def get_giants_directory() -> pathlib.Path:
|
||||||
|
return pathlib.Path(CONFIG["giants_directory"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_datadir() -> pathlib.Path:
|
||||||
|
home = pathlib.Path.home()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return home / "AppData/Roaming"
|
||||||
|
elif sys.platform == "linux":
|
||||||
|
return home / ".local/share"
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
return home / "Library/Application Support"
|
||||||
|
|
||||||
|
|
||||||
|
class MapsBackground(QThread):
|
||||||
|
msg = Signal(str)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
self.log("Background task starting")
|
||||||
|
try:
|
||||||
|
self.server_up_or_dl_missing_map(LOCAL_IP, 19711, True, timeout=2)
|
||||||
|
except:
|
||||||
|
self.log("No local server found")
|
||||||
|
self.all_servers_download()
|
||||||
|
|
||||||
|
def log(self, message: str):
|
||||||
|
self.msg.emit(message)
|
||||||
|
|
||||||
|
def server_up_or_dl_missing_map(self, server_ip: str, server_port: int, is_local: bool, timeout=5) -> None:
|
||||||
|
enum_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
enum_socket.setblocking(True)
|
||||||
|
enum_socket.settimeout(timeout)
|
||||||
|
|
||||||
|
eq = EnumQuery()
|
||||||
|
eq.Type = EnumQuery.HAS_APPLICATION_GUID
|
||||||
|
eq.ApplicationGUID = AppGUID.Release
|
||||||
|
p = eq.to_packet().getvalue()
|
||||||
|
enum_socket.sendto(p, (server_ip, server_port))
|
||||||
|
|
||||||
|
response = enum_socket.recv(1024)
|
||||||
|
enumresp = EnumResponse.parse(Packet(response))
|
||||||
|
giantsenumresp = GiantsEnumResponseParser.parse(enumresp)
|
||||||
|
if is_local:
|
||||||
|
self.upload_map(installed_maps[giantsenumresp.map_checksum])
|
||||||
|
elif giantsenumresp.map_checksum not in installed_maps:
|
||||||
|
self.log(f"Map {giantsenumresp.map_name} not found locally, downloading it")
|
||||||
|
self.download_map(giantsenumresp.map_checksum)
|
||||||
|
|
||||||
|
def download_map(self, crc: int) -> None:
|
||||||
|
self.log(f"Downloading map {API_BASE_URL}/map?crc={crc}")
|
||||||
|
try:
|
||||||
|
req = requests.get(f"{API_BASE_URL}/map?crc={crc}")
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error while fetching map info: {e}")
|
||||||
|
return
|
||||||
|
if req.status_code != 200:
|
||||||
|
self.log(f"Could not download map from server, got status_code {req.status_code}")
|
||||||
|
return
|
||||||
|
j = req.json()
|
||||||
|
blob_location = j["blob_location"]
|
||||||
|
|
||||||
|
final_map_file = get_maps_dir() / j["name"]
|
||||||
|
|
||||||
|
with requests.get(blob_location, stream=True) as stream:
|
||||||
|
with open(final_map_file, 'wb') as f:
|
||||||
|
for chunk in stream.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
load_installed_maps()
|
||||||
|
self.log(f"Download succesful: {final_map_file}")
|
||||||
|
|
||||||
|
def upload_map(self, map_path: pathlib.Path) -> None:
|
||||||
|
with open(map_path, "rb") as fp:
|
||||||
|
content_bytes = fp.read()
|
||||||
|
|
||||||
|
def crc(map_content_bytes):
|
||||||
|
prev = zlib.crc32(map_content_bytes, 0)
|
||||||
|
return prev & 0xffffffff
|
||||||
|
|
||||||
|
map_crc = crc(content_bytes)
|
||||||
|
|
||||||
|
# check if map is known by server
|
||||||
|
self.log(f"Trying {API_BASE_URL}/map?crc={map_crc}")
|
||||||
|
try:
|
||||||
|
req = requests.get(f"{API_BASE_URL}/map?crc={map_crc}")
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"Error while fetching map info: {e}")
|
||||||
|
return
|
||||||
|
if req.status_code == 200:
|
||||||
|
self.log(f"API already knows this map, got response {req.status_code}")
|
||||||
|
return
|
||||||
|
|
||||||
|
map_name = os.path.basename(map_path)
|
||||||
|
self.log(f"Local server found hosting map {map_name}, uploading it")
|
||||||
|
b64_data = base64.b64encode(content_bytes).decode("utf8")
|
||||||
|
map_data = {"name": map_name, "b64_data": b64_data}
|
||||||
|
try:
|
||||||
|
req = requests.post(f"{API_BASE_URL}/maps", json=map_data)
|
||||||
|
req.raise_for_status()
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f"There was an error while uploading map: {e}")
|
||||||
|
return
|
||||||
|
self.log("Upload successful")
|
||||||
|
|
||||||
|
def all_servers_download(self) -> None:
|
||||||
|
all_servers = requests.get("https://giants.azurewebsites.net/api/Servers").json()
|
||||||
|
for server in all_servers:
|
||||||
|
try:
|
||||||
|
self.server_up_or_dl_missing_map(server["hostIpAddress"], server["port"], False)
|
||||||
|
except socket.timeout:
|
||||||
|
self.log(f"Could not connect to {server['hostIpAddress']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setQuitOnLastWindowClosed(True)
|
||||||
|
read_configuration()
|
||||||
|
load_installed_maps()
|
||||||
|
window = MainWindow()
|
||||||
|
window.show()
|
||||||
|
sys.exit(app.exec())
|
40
main.spec
Normal file
40
main.spec
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
|
|
||||||
|
|
||||||
|
block_cipher = None
|
||||||
|
|
||||||
|
|
||||||
|
a = Analysis(['main.py'],
|
||||||
|
pathex=[],
|
||||||
|
binaries=[],
|
||||||
|
datas=[],
|
||||||
|
hiddenimports=[],
|
||||||
|
hookspath=[],
|
||||||
|
hooksconfig={},
|
||||||
|
runtime_hooks=[],
|
||||||
|
excludes=['PySide6.QtQml'],
|
||||||
|
win_no_prefer_redirects=False,
|
||||||
|
win_private_assemblies=False,
|
||||||
|
cipher=block_cipher,
|
||||||
|
noarchive=False)
|
||||||
|
pyz = PYZ(a.pure, a.zipped_data,
|
||||||
|
cipher=block_cipher)
|
||||||
|
|
||||||
|
exe = EXE(pyz,
|
||||||
|
a.scripts,
|
||||||
|
a.binaries,
|
||||||
|
a.zipfiles,
|
||||||
|
a.datas,
|
||||||
|
[],
|
||||||
|
name='main',
|
||||||
|
debug=False,
|
||||||
|
bootloader_ignore_signals=False,
|
||||||
|
strip=False,
|
||||||
|
upx=True,
|
||||||
|
upx_exclude=[],
|
||||||
|
runtime_tmpdir=None,
|
||||||
|
console=True,
|
||||||
|
disable_windowed_traceback=False,
|
||||||
|
target_arch=None,
|
||||||
|
codesign_identity=None,
|
||||||
|
entitlements_file=None )
|
96
maps_gui.py
Normal file
96
maps_gui.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
## Form generated from reading UI file 'maps_gui.ui'
|
||||||
|
##
|
||||||
|
## Created by: Qt User Interface Compiler version 6.2.2
|
||||||
|
##
|
||||||
|
## WARNING! All changes made in this file will be lost when recompiling UI file!
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale,
|
||||||
|
QMetaObject, QObject, QPoint, QRect,
|
||||||
|
QSize, QTime, QUrl, Qt)
|
||||||
|
from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
|
||||||
|
QFont, QFontDatabase, QGradient, QIcon,
|
||||||
|
QImage, QKeySequence, QLinearGradient, QPainter,
|
||||||
|
QPalette, QPixmap, QRadialGradient, QTransform)
|
||||||
|
from PySide6.QtWidgets import (QApplication, QCheckBox, QFormLayout, QGridLayout,
|
||||||
|
QLabel, QMainWindow, QMenuBar, QPlainTextEdit,
|
||||||
|
QPushButton, QSizePolicy, QStatusBar, QWidget)
|
||||||
|
|
||||||
|
class Ui_MainWindow(object):
|
||||||
|
def setupUi(self, MainWindow):
|
||||||
|
if not MainWindow.objectName():
|
||||||
|
MainWindow.setObjectName(u"MainWindow")
|
||||||
|
MainWindow.resize(449, 230)
|
||||||
|
self.centralwidget = QWidget(MainWindow)
|
||||||
|
self.centralwidget.setObjectName(u"centralwidget")
|
||||||
|
self.centralwidget.setEnabled(True)
|
||||||
|
self.gridLayout_2 = QGridLayout(self.centralwidget)
|
||||||
|
self.gridLayout_2.setObjectName(u"gridLayout_2")
|
||||||
|
self.gridLayout = QGridLayout()
|
||||||
|
self.gridLayout.setObjectName(u"gridLayout")
|
||||||
|
self.formLayout = QFormLayout()
|
||||||
|
self.formLayout.setObjectName(u"formLayout")
|
||||||
|
self.automaticMapDownloadLabel = QLabel(self.centralwidget)
|
||||||
|
self.automaticMapDownloadLabel.setObjectName(u"automaticMapDownloadLabel")
|
||||||
|
|
||||||
|
self.formLayout.setWidget(0, QFormLayout.LabelRole, self.automaticMapDownloadLabel)
|
||||||
|
|
||||||
|
self.automaticMapDownloadCheckBox = QCheckBox(self.centralwidget)
|
||||||
|
self.automaticMapDownloadCheckBox.setObjectName(u"automaticMapDownloadCheckBox")
|
||||||
|
|
||||||
|
self.formLayout.setWidget(0, QFormLayout.FieldRole, self.automaticMapDownloadCheckBox)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout.addLayout(self.formLayout, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
self.pushButton = QPushButton(self.centralwidget)
|
||||||
|
self.pushButton.setObjectName(u"pushButton")
|
||||||
|
self.pushButton.setEnabled(True)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.pushButton, 0, 1, 1, 1)
|
||||||
|
|
||||||
|
self.logEdit = QPlainTextEdit(self.centralwidget)
|
||||||
|
self.logEdit.setObjectName(u"logEdit")
|
||||||
|
self.logEdit.setReadOnly(True)
|
||||||
|
|
||||||
|
self.gridLayout.addWidget(self.logEdit, 1, 0, 1, 2)
|
||||||
|
|
||||||
|
|
||||||
|
self.gridLayout_2.addLayout(self.gridLayout, 0, 0, 1, 1)
|
||||||
|
|
||||||
|
MainWindow.setCentralWidget(self.centralwidget)
|
||||||
|
self.menubar = QMenuBar(MainWindow)
|
||||||
|
self.menubar.setObjectName(u"menubar")
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 449, 21))
|
||||||
|
MainWindow.setMenuBar(self.menubar)
|
||||||
|
self.statusbar = QStatusBar(MainWindow)
|
||||||
|
self.statusbar.setObjectName(u"statusbar")
|
||||||
|
MainWindow.setStatusBar(self.statusbar)
|
||||||
|
|
||||||
|
self.retranslateUi(MainWindow)
|
||||||
|
|
||||||
|
QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
# setupUi
|
||||||
|
|
||||||
|
def retranslateUi(self, MainWindow):
|
||||||
|
MainWindow.setWindowTitle(QCoreApplication.translate("MainWindow", u"GMD - Giants Maps Downloader", None))
|
||||||
|
self.automaticMapDownloadLabel.setText(QCoreApplication.translate("MainWindow", u"Automatic map download", None))
|
||||||
|
#if QT_CONFIG(statustip)
|
||||||
|
self.automaticMapDownloadCheckBox.setStatusTip(QCoreApplication.translate("MainWindow", u"Every few minutes, all servers will be queried and missing maps will be downloaded", None))
|
||||||
|
#endif // QT_CONFIG(statustip)
|
||||||
|
#if QT_CONFIG(statustip)
|
||||||
|
self.pushButton.setStatusTip(QCoreApplication.translate("MainWindow", u"Immediately query servers now, download missing maps and upload hosted ones", None))
|
||||||
|
#endif // QT_CONFIG(statustip)
|
||||||
|
self.pushButton.setText(QCoreApplication.translate("MainWindow", u"Download currently missing maps\n"
|
||||||
|
"hosted by servers", None))
|
||||||
|
#if QT_CONFIG(statustip)
|
||||||
|
self.logEdit.setStatusTip(QCoreApplication.translate("MainWindow", u"Log messages", None))
|
||||||
|
#endif // QT_CONFIG(statustip)
|
||||||
|
#if QT_CONFIG(statustip)
|
||||||
|
self.statusbar.setStatusTip(QCoreApplication.translate("MainWindow", u"By Amazed#0001", None))
|
||||||
|
#endif // QT_CONFIG(statustip)
|
||||||
|
# retranslateUi
|
||||||
|
|
87
maps_gui.ui
Normal file
87
maps_gui.ui
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<ui version="4.0">
|
||||||
|
<class>MainWindow</class>
|
||||||
|
<widget class="QMainWindow" name="MainWindow">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>449</width>
|
||||||
|
<height>230</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
<property name="windowTitle">
|
||||||
|
<string>GMD - Giants Maps Downloader</string>
|
||||||
|
</property>
|
||||||
|
<widget class="QWidget" name="centralwidget">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<layout class="QGridLayout" name="gridLayout_2">
|
||||||
|
<item row="0" column="0">
|
||||||
|
<layout class="QGridLayout" name="gridLayout">
|
||||||
|
<item row="0" column="0">
|
||||||
|
<layout class="QFormLayout" name="formLayout">
|
||||||
|
<item row="0" column="0">
|
||||||
|
<widget class="QLabel" name="automaticMapDownloadLabel">
|
||||||
|
<property name="text">
|
||||||
|
<string>Automatic map download</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="QCheckBox" name="automaticMapDownloadCheckBox">
|
||||||
|
<property name="statusTip">
|
||||||
|
<string>Every few minutes, all servers will be queried and missing maps will be downloaded</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
<item row="0" column="1">
|
||||||
|
<widget class="QPushButton" name="pushButton">
|
||||||
|
<property name="enabled">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
<property name="statusTip">
|
||||||
|
<string>Immediately query servers now, download missing maps and upload hosted ones</string>
|
||||||
|
</property>
|
||||||
|
<property name="text">
|
||||||
|
<string>Download currently missing maps
|
||||||
|
hosted by servers</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
<item row="1" column="0" colspan="2">
|
||||||
|
<widget class="QPlainTextEdit" name="logEdit">
|
||||||
|
<property name="statusTip">
|
||||||
|
<string>Log messages</string>
|
||||||
|
</property>
|
||||||
|
<property name="readOnly">
|
||||||
|
<bool>true</bool>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</item>
|
||||||
|
</layout>
|
||||||
|
</widget>
|
||||||
|
<widget class="QMenuBar" name="menubar">
|
||||||
|
<property name="geometry">
|
||||||
|
<rect>
|
||||||
|
<x>0</x>
|
||||||
|
<y>0</y>
|
||||||
|
<width>449</width>
|
||||||
|
<height>21</height>
|
||||||
|
</rect>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
<widget class="QStatusBar" name="statusbar">
|
||||||
|
<property name="statusTip">
|
||||||
|
<string>By Amazed#0001</string>
|
||||||
|
</property>
|
||||||
|
</widget>
|
||||||
|
</widget>
|
||||||
|
<resources/>
|
||||||
|
<connections/>
|
||||||
|
</ui>
|
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
PySide6
|
||||||
|
requests
|
||||||
|
pyyaml
|
||||||
|
pyinstaller
|
Loading…
Reference in New Issue
Block a user