diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f00dad2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__ +build +dist +*.spec +test_folder +.venv diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c96a1f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ +BSD-3-Clause + +Copyright 2026 Kairoto + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 6a5e6cb..c85e8dd 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,43 @@ -GVM -=============== +# GVM (WIP) -A Godot Version Manager written in Python \ No newline at end of file +A Godot Version Manager written in Python. +You can download launch and remove stable Godot versions.
+It's for people who want an unified experience of managing Godot +instead of losing track of every sole version they use. + +## Dependencies +You need **[requests](https://pypi.org/project/requests/)** for the application to work.
+Pyinstaller is only needed for building. +

+Windows only:
+To implement the icon, install pillow pip install pillow and add -i img/icon.svg to the pyinstaller command. + +## Building from source + +This project is supposed to only run on Linux and Windows. + +### Windows + +
    +
  1. Install [Python](https://www.python.org/)
  2. +
  3. You have to clone the repo. (git clone http://kairoto.com:8080/git/Kairoto/GVM.git)
  4. +
  5. Install the needed dependencies. (pip install requests pyinstaller)
  6. +
  7. Use pyinstaller to build the app. (pyinstaller -D -w -F -n GVM.exe main.py)
  8. +
+Our pyinstaller command is recommended and tested. + +### Linux + +
    +
  1. Install [Python](https://www.python.org/) suiting your distro.
  2. +
  3. Install pip suiting your distro too. +
  4. You have to clone the repo. (git clone http://kairoto.com:8080/git/Kairoto/GVM.git)

  5. +

    If your system has problems with building, do this: +

      +
    1. Create a virtual environment by using python -m venv {ENV_NAME}.
    2. +
    3. Activate the virtual environment (source {venv-folder}/bin/activate).
    +


    +
  6. Install the needed dependencies. (pip install requests pyinstaller)
  7. +
  8. Use pyinstaller to build the app. (pyinstaller -D -F -n GVM.x86_64 main.py)
  9. +
+Our pyinstaller command is recommended and tested. diff --git a/backend.py b/backend.py new file mode 100644 index 0000000..a5a2cd2 --- /dev/null +++ b/backend.py @@ -0,0 +1,295 @@ +import platform +import requests +from os import remove,mkdir +import subprocess +import shutil +from time import sleep +import re +import pathlib +import json +import socket + +def checkConnection(host="8.8.8.8", port=53, timeout=3): + try: + socket.setdefaulttimeout(timeout) + socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) + return True + except socket.error: + return False + +def regainedConnection() -> bool : + global lostConnection + checkedConnection = checkConnection() + if checkedConnection == True : + if checkedConnection == lostConnection : + lostConnection = not checkedConnection + return True + else : + return False + else : + lostConnection = not checkedConnection + return True + +lostConnection = True + +def getArch() -> str : + arch = platform.machine() + + if arch in ['x86_64','AMD64'] : + return 'x86_64' + elif arch in ['arm64','aarch64'] : + return 'arm64' + +OS = platform.system() + +if OS == 'Linux' : + APPDATA = f"{pathlib.Path.home()}/.gvm_data/" +elif OS == 'Windows' : + APPDATA =f"{pathlib.Path.home()}\\AppData\\Roaming\\GVM\\" + +FIRSTOPEN = not pathlib.Path(f"{APPDATA}settings.json").exists() + +if FIRSTOPEN : + try : + mkdir(APPDATA) + except : + pass + finally : + open(f"{APPDATA}settings.json",'w').write(json.dumps({'appPath':'','token':''})) + + +SETTINGS = json.load(open(f"{APPDATA}settings.json",'r')) + +def updateSettings(appPath: str, token: str) -> None : + global APP_PATH + global TOKEN + APP_PATH = appPath + TOKEN = token + +APP_PATH: str = SETTINGS['appPath'] +WIN_COMP: str = APP_PATH.replace('/','\\') +TOKEN: str = SETTINGS['token'] + +ARCH = getArch() + +def requestGitHubInfo() -> list : + if len(TOKEN) > 0 : + response = requests.get("https://api.github.com/repos/godotengine/godot/releases?per_page=100",headers={"Accept":"application/vnd.github+json","Authorization":f"Bearer {TOKEN}","X-GitHub-Api-Version":"2022-11-28"}) + else : + return [] + + return response.json() + +def loadAltOptions(mono: str) -> list : + installed_files = getAllFiles(mono) + installed_files.sort(reverse=True) + if installed_files != [] : + return installed_files + else: + return ['No options available'] + +# Get the versions of the Godot Engine and sort them from latest to oldest + +def verOptions(mono: str, curVersion: list|None) -> list: + tag_list = [] + + altOptions = loadAltOptions(mono) + + if not checkConnection() : + return altOptions + elif curVersion == None and checkConnection() : + response = requestGitHubInfo() + elif curVersion == altOptions and checkConnection() : + response = requestGitHubInfo() + elif curVersion != altOptions : + return altOptions + else : + return curVersion + + if type(response) == dict or response == [] : + return altOptions + + for i in response : + tag_list.append(i['tag_name']) + + if tag_list != [] : + tag_list.sort(reverse=True) + tag_list.pop() + else : + return altOptions + + return tag_list + +def downloadCmd(asset_url: str) -> int : + curlStr = f"curl -Lo {APP_PATH}/Godot.zip {asset_url}" + if subprocess.run(curlStr.split(' ')).returncode != 0 : + return 1 + + if OS == 'Linux' : + unzipStr = f"unzip -d {APP_PATH} {APP_PATH}/Godot.zip" + elif OS == 'Windows' : + unzipStr = f"tar -xf {APP_PATH}/Godot.zip -C {APP_PATH}" + + if subprocess.run(unzipStr.split(' ')).returncode != 0 : + return 1 + + remove(f"{APP_PATH}/Godot.zip") + + return 0 + + +def downloadGodot(cur_version: str,mono: str) -> int : + + if not pathlib.Path(APP_PATH).exists() : + return 1 + + response = requestGitHubInfo() + + if type(response) == dict : + return 1 + + VERSION = cur_version + + response.sort(key=lambda x: x['tag_name'],reverse=True) + + ASSETS = getAssets(response, VERSION) + + if ASSETS != [] : + for asset in ASSETS : + if OS == 'Linux' : + regex: list = re.findall("linux|x11",asset['name']) + re.findall("x86_64|64",asset['name']) + + if mono != 'no_mono' : + regex += re.findall(mono,asset['name']) + monoCatch = "" + monoGoal = ['mono'] + else : + monoCatch = "|mono" + monoGoal = [] + + + if re.findall(f"server|headless{monoCatch}",asset['name']) == [] and regex in [['linux','x86_64']+monoGoal,['x11','64']+monoGoal] : + return downloadCmd(asset['browser_download_url]']) + + elif OS == 'Windows' : + + if mono != 'no_mono' : + monoGoal = ['mono'] + else : + monoGoal = [] + + if ['win','arm64'] + monoGoal == re.findall('win',asset['name']) + re.findall('arm64',asset['name']) + re.findall('mono',asset['name']) : + return downloadCmd(asset['browser_download_url]']) + + elif ['win64'] + monoGoal == re.findall('win64',asset['name']) + re.findall('mono',asset['name']) : + return downloadCmd(asset['browser_download_url]']) + else : + return 1 + else : + return 1 + +def getAssets(response: list, tag: str) -> list : + + for i in response : + if i['tag_name'] == tag : + return i['assets'] + else : + continue + + return [] + +def launch(cur_version: str, mono: str) -> int : + + if not pathlib.Path(APP_PATH).exists() : + return 1 + + VERSION = cur_version.split('-')[0] + + file = getFile(VERSION,mono) + + if file.exists() and not file.is_dir() : + popen(f"{file.as_posix()}") + return 0 + elif file.exists() and file.is_dir() : + for exe in file.iterdir() : + if re.findall('exe|x86_64|64',exe.name) and exe.is_file() : + popen(f"{exe.as_posix()}") + else : + return 1 + +def getFile(cur_version: str, mono: str) -> pathlib.Path | int : + + if not pathlib.Path(APP_PATH).exists() : + return 1 + + VERSION = cur_version.split('-')[0] + + appDir = pathlib.Path(APP_PATH) + + if mono == 'no_mono' : + for i in appDir.iterdir() : + if re.findall(f"{VERSION}-|{VERSION}_",i.name) in [[f"{VERSION}-"],[f"{VERSION}_"]] and i.is_file() : + return i + elif mono == 'mono' : + for i in appDir.iterdir() : + if re.findall(f"{VERSION}-|{VERSION}_",i.name) + re.findall('mono',i.name) in [[f"{VERSION}-",'mono'],[f"{VERSION}_",'mono']] and i.is_dir() : + return i + + return 1 + +def getAllFiles(mono: str) -> list : + if not pathlib.Path(APP_PATH).exists() : + return [] + + files = [] + + appDir = pathlib.Path(APP_PATH) + + if mono == 'no_mono' : + for i in appDir.iterdir() : + if re.findall("-stable|_stable",i.name) in [["-stable"],["_stable"]] and i.is_file() : + if re.findall("_stable",i.name) in ["_stable"] : + i.name.replace("_stable","-stable") + + files.append(re.findall("[1-9].+-stable",i.name)[0]) + elif mono == 'mono' : + for i in appDir.iterdir() : + if re.findall("-stable|_stable",i.name) + re.findall('mono',i.name) in [["-stable",'mono'],["_stable",'mono']] and i.is_dir() : + if re.findall("_stable",i.name) in ["_stable"] : + i.name.replace("_stable","-stable") + + files.append(re.findall("[1-9].+-stable",i.name)[0]) + + if files != [] : + return files + else : + return [] + +def suddenSet(appPath: str, token: str) -> None : + open(f"{APPDATA}settings.json",'w').write(json.dumps({'appPath':appPath,'token':token})) + updateSettings(appPath,token) + +def checkSettings() -> bool : + settings = json.load(open(f"{APPDATA}settings.json",'r')) + + if settings['appPath'] == '' or settings['token'] == '' : + return False + else : + return True + +def removeGodot(cur_version: str, mono: str) -> int : + if not pathlib.Path(APP_PATH).exists() : + return 1 + + VERSION = cur_version.split('-')[0] + + file = getFile(VERSION,mono) + + if file.exists() : + if file.is_file() : + remove(f"{file.as_posix()}") + else : + shutil.rmtree(file.as_posix()) + return 0 + else : + return 1 \ No newline at end of file diff --git a/img/icon.png b/img/icon.png new file mode 100644 index 0000000..4e50264 --- /dev/null +++ b/img/icon.png Binary files differ diff --git a/img/icon.svg b/img/icon.svg new file mode 100644 index 0000000..43f6ecc --- /dev/null +++ b/img/icon.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..f5cb9ae --- /dev/null +++ b/main.py @@ -0,0 +1,154 @@ +import tkinter as tk +import tkinter.ttk as ttk +import tkinter.filedialog as fd +import backend +import pathlib + +def tryDownload() -> None : + if backend.checkSettings() : + if backend.downloadGodot(strvar.get(),mono.get()) == 0 : + print('Download was successful') + else : + print('Something went wrong') + + else : + openSettings() + + isInstalled() + +def tryLaunch() -> None : + if backend.checkSettings() : + if backend.launch(strvar.get(),mono.get()) == 0 : + print("Launched successfully") + else : + print("Godot couldn't be launched") + else : + openSettings() + +def tryRemove() -> None : + if backend.checkSettings() : + if backend.removeGodot(strvar.get(),mono.get()) == 0 : + print(f"Godot {strvar.get()} was removed.") + else : + print("Couldn't be removed.") + else : + openSettings() + + isInstalled() + +def getAppDir() -> None : + app_path.set(fd.askdirectory(initialdir=pathlib.Path.home(),mustexist=True)) + +def isInstalled(*event) -> None : + global strvar + DOWNLOAD_BUTTON = tk.Button(CANVAS,activebackground="#aaa",bg="#888",text="Download",command=tryDownload) + REMOVE_BUTTON = tk.Button(CANVAS,activebackground="#aaa",bg="#888",text="Remove",command=tryRemove) + + if backend.getFile(strvar.get(),mono.get()) == 1 and backend.TOKEN != '' : + try : + REMOVE_BUTTON.destroy() + except : + pass + finally : + DOWNLOAD_BUTTON.place(width=72,height=36,relx=0.34,y=380) + else : + try : + DOWNLOAD_BUTTON.destroy() + except : + pass + finally : + REMOVE_BUTTON.place(width=72,height=36,relx=0.34,y=380) + + +def quit() -> None : + FRONTEND.quit() + +def openSettings() -> None : + + def saveSettings() -> None : + backend.suddenSet(dirSetting.get(),tokenSetting.get()) + updateVersionList() + FRONTEND.event_generate('<>',when='tail') + settingsWindow.destroy() + + settingsWindow = tk.Toplevel(FRONTEND) + settingsWindow.resizable(False,False) + settingsWindow.title('Settings') + settingsWindow.geometry("480x320") + + settingsCanvas = tk.Canvas(settingsWindow,bg="#5095A7") + settingsCanvas.place(relheight=1,relwidth=1) + + tk.Label(settingsCanvas,bg="#5095A7",text="PATH:").place(x=100,y=36,height=24,width=40) + tk.Label(settingsCanvas,bg="#5095A7",text="GitHub-Token:").place(x=100,y=100,height=24,width=90) + dirSetting: tk.Entry = tk.Entry(settingsCanvas,bd=1,bg="#aaa",textvariable=app_path) + dirSetting.place(height=24,width=240,x=96,y=60) + tk.Button(settingsCanvas,text="...",activebackground='#aaa',bg='#888',command=getAppDir).place(height=24,width=24,x=336,y=60) + tokenSetting: tk.Entry = tk.Entry(settingsCanvas,bd=1,bg="#aaa",show='*') + tokenSetting.place(height=24,width=264,x=96,y=124) + tk.Button(settingsCanvas,text="Save",activebackground='#aaa',bg='#888',command=saveSettings).place(height=36,width=90,x=195,y=180) + +def updateVersionList() -> None : + global CUR_VERSION + global VERSION_MENU + global strvar + updatedList = backend.verOptions(mono.get(),CUR_VERSION) + if CUR_VERSION != updatedList or VERSION_MENU.winfo_exists() : + CUR_VERSION = updatedList + strvar = tk.StringVar(CANVAS,CUR_VERSION[0]) + VERSION_MENU = ttk.Combobox(master=CANVAS,textvariable=strvar,values=CUR_VERSION,state='readonly',font="Sans 12",style='M.TCombobox') + VERSION_MENU.bind('<>',isInstalled) + VERSION_MENU.bind('',isInstalled) + VERSION_MENU.place(width=256,height=36,relx=0.31,y=120) + + isInstalled() + +def setMono() -> None : + MONO_CHECK.event_generate('<>',when='tail') + +CUR_VERSION = None + +FRONTEND = tk.Tk() +FRONTEND.geometry("650x480") +FRONTEND.resizable(False,False) +FRONTEND.title("GVM") +icon = tk.PhotoImage("img/icon.png") +FRONTEND.iconphoto(True,icon) +app_path = tk.StringVar(FRONTEND,backend.APP_PATH) + +ttk.Style().configure("M.TCombobox",background="#888") +ttk.Style().map("M.TCombobox",background=[('active','#aaa')]) + +CANVAS = tk.Canvas(FRONTEND,bg="#11a9a9") +LAUNCH_BUTTON = tk.Button(CANVAS,activebackground="#aaa",bg="#888",text="Launch",command=tryLaunch) +DOWNLOAD_BUTTON = tk.Button(CANVAS,activebackground="#aaa",bg="#888",text="Download",command=tryDownload) +REMOVE_BUTTON = tk.Button(CANVAS,activebackground="#aaa",bg="#888",text="Remove",command=tryRemove) + +mono = tk.StringVar(CANVAS,'no_mono') + + +MONO_CHECK = tk.Checkbutton(CANVAS,text="Mono Version",bg="#45b6be",fg='#000',activebackground="#4ed8cc",activeforeground="#000",offvalue='no_mono',onvalue='mono',variable=mono,command=updateVersionList) + +Menubar = tk.Menu(CANVAS,type='menubar') + +file_menu = tk.Menu(Menubar,tearoff=False) + +file_menu.add_command(label='Settings',command=openSettings) +file_menu.add_separator() +file_menu.add_command(label='Exit',command=quit) + +Menubar.add_cascade(label='File',menu=file_menu) + +CANVAS.place(relheight=1,relwidth=1) +CANVAS.create_image(120,20) +CANVAS.create_text(325,36,font="JetBrainsMono 24",text="Godot Version Manager") +LAUNCH_BUTTON.place(width=72,height=36,relx=0.55,y=380) +updateVersionList() +MONO_CHECK.place(width=128,height=24,relx=0.75,y=126) + +#help line +#tk.Canvas(bg="#ff0000").place(relheight=1,width=2,x=325) + +FRONTEND.configure(menu=Menubar) + +FRONTEND.mainloop()