summaryrefslogblamecommitdiff
path: root/__main__.py
blob: 43a447cde093af792876fcdc66095390dbcf343e (plain) (tree)
1
2
3
4
5
6
7
8
9
10
11










                                     
                                                                         
                                                                              
                    
                                                            
                             










                                                                            
                                                      

                                 








                                           
                                         
                                                          

                                    
                                        



                                                
                                                 






                                                               
                                                                        








                                                                                                          
                           

                       










                                                     

                                                                 

                                     



                                                 

































                                                                                                                                                                                           


                                          
 
                                         


               
                           
























                                                             
                     










                                                                                                                                                              
                                                                       










                                                                    





                                             
                                                      











                                             
 





















                                                                             


                                          

                                   

                                          
                                          

                           
                                                                




                            
         






                                                          
                                                                      
                               


                                              
                                                         
             
                                                                                               
            

                      
                           











                                                                                                                               




                             

                                                                                 
                   

                                                                
                
                                                                            
                                         








                                                        
                                                                     

                                                                  
                                                                                                                                                               

                                               



                                                                                                                                            

                  

          
                                                                                 




















































                                                                                                                                   
                                                       
                   
                                                                  





                                                                                 

                                                            














                                                               
                                                                                      








                                                                  
                                                                                      










                                                              
                                                                                                       










                                                          
                                                                                 
   
               

















                                                                        

                                                           
   
#!/usr/bin/python3
#####################################
## This is the Tkinter version
## It is mostly a TMW Vault Wrapper
##
## For lore, you need to use the app
#####################################
## (C) The Mana World Team, 2021-2024
## Published under the MIT License
#####################################

import requests, json, traceback, subprocess, sys, time, os, uuid, base64
import threading #hmac, struct # < - TODO: Necessary for TOTP-remember support
import tkinter as tk
from tkinter.messagebox import showinfo, showerror, askyesno
from functools import partial

OBFS = uuid.getnode() % 256 # Get a number which *usually* doesn't change
print("Obfuscation key = %d" % OBFS) # <- Just to not leave it in plain text

## Internal wrapper for preferences
saveSettings = True
def _savePref():
    global saveSettings, pref
    if not saveSettings:
        return
    dump = json.dumps(pref)
    dump = bytearray(ord(x) ^ int(OBFS) for x in dump)
    dump = base64.b64encode(dump)
    dump = dump.decode('ascii')
    with open("settings.b64", 'w') as raw:
        raw.write(str(dump))
    #print("Preferences updated...")
    return

## Try to load previously saved settings
try:
    with open("settings.b64") as raw:
        pref = base64.b64decode(raw.read())
        ## XXX: use ord() if int(x) fails
        pref = bytearray(int(x) ^ int(OBFS) for x in pref)
        pref = pref.decode('utf8')
        pref = json.loads(str(pref))
        print("User settings loaded...")
except:
    print("Failed to load user settings!")
    err = traceback.format_exc().split("\n")[-2]
    print("Error: %s" % err)
    ## Create new dummy prefs so it doesn't crash
    pref = {"user": "",    # <- User Email
            "pass": "",    # <- User Password
            "totp": "",    # <- NYI
            "mana": False, # <- Prefer Mana over ManaVerse
            "plus": False, # <- Prefer ManaPlus over ManaVerse
            "local": True, # <- system-wide vs manaplus/ folder
            "shell": True} # <- output to console
    ## If it is an error, give an option to not destroy the old settings
    if ("FileNotFoundError" in err):
        _savePref()
        print("New user settings created!")
    elif (askyesno("Mana Launcher", "Failed to load user settings!\n\nDo you want to create a new one?")):
        _savePref()
        print("New user settings created!")
    else:
        saveSettings = False

## Prepare some basic stuff
execute=subprocess.call
serverlist = []
class HoverButton(tk.Button):
    def enter(self, e):
        self["background"] = self["activebackground"]
    def exit(self, e):
        self["background"] = self.defaultBackground

    def __init__(self, **kw):
        tk.Button.__init__(self, **kw)
        self.defaultBackground = self["background"]
        self.bind("<Enter>", self.enter)
        self.bind("<Leave>", self.exit)

## TODO: Get from `sys` args parameters like world auto-selection

#####################################
#VAULT_HOST = "https://localhost:13370"
#SERVERLIST = "http://localhost/launcher"
VAULT_HOST = "https://api.themanaworld.org:13370"
SERVERLIST = "https://updates.tmw2.org/launcher"
# Vault SSL wrapper
vault=requests.Session()
if "localhost" in VAULT_HOST:
    vault.verify=False
#####################################

def stdout(message, bd=False):
    if bd:
        print("\033[1m%s\033[0m" % message)
    else:
        print(message)

def sdelay(delta=0.02):
    time.sleep(delta)
    return

# IF Then Else (IFTE)
def ifte(ifs, then, elses):
    if (ifs):
        return then
    else:
        return elses

# Sanitize a command (strip some flow control chars)
# While it covers all control operators and most metacharacters,
# it doesn't covers well the reserved words.
# ...Of course, it relies on this client not being compromised.
def san(cmd):
    return cmd.replace(";", "").replace("|", "").replace(">", "").replace("<", "").replace("&", "").replace("(", "").replace(")", "").replace("\n", "").replace("[[", "").replace("]]", "")

# Returns number of seconds since UNIX EPOCH
def now():
    return int(time.time())

## Update preferences (only after release)
#if not "local" in list(pref.keys()):
#    pref["local"] = True

## Does nothing, validation purposes only
def nullp(arg):
    return

## Validates a server entry
def validate_server(srv):
    name="Unknown Server"; ok=False
    try:
        name=srv["Name"]
        #stdout("Validating server \"%s\"..." % name)
        nullp("Host: %s" % srv["Host"])
        nullp("Port: %04d" % srv["Port"])
        nullp("Desc: %s" % srv["Desc"])
        nullp("Link: %s" % srv["Link"])
        nullp("News: %s" % srv["News"])
        nullp("Back: %s" % srv["Back"])
        nullp("UUID: %s" % srv["UUID"])
        nullp("Help: %s" % srv["Help"])
        nullp("Online List: %s" % srv["Online"])
        nullp("Policy: %s" % srv["Policy"])
        if not "Type" in srv.keys():
            srv["Type"]="evol2"
        stdout("Server \"%s\" loaded." % name)
        ok=True
    except:
        traceback.print_exc()
        stdout("Validation for server \"%s\" FAILED!" % name)

    return ok

## Update server list
def update_serverlist(hosti):
    global serverlist, host
    try:
        r=requests.get("%s/server_list.json" % hosti, timeout=10.0)
        if (r.status_code != 200):
            raise AssertionError("Mirror %s seems to be down!\nReturned error %03d\n" % (hosti.replace("https://", "").replace("http://", ""), r.status_code))

        j=json.loads(r.text)

        ## If we reached here, then everything is likely fine
        ## We can now build the persistent data
        print("Fetching server list and assets... %d servers" % len(j))
        for server in j:
            if (validate_server(server)):
                serverlist.append(server)

        ## If we were truly successful, save this host as our mirror
        host = hosti
        return True
    except:
        traceback.print_exc()
        return False

## Show a game info
def info_game(idx):
    showinfo(title=serverlist[idx]["Name"],
             message=serverlist[idx]["Desc"])
    return

## Launch a specific world with its appropriate client
def launch_game(idx):
    ## Get/Set basic data
    HOST=serverlist[idx]["Host"]
    PORT=serverlist[idx]["Port"]
    CMD=""
    OPT=""
    PWD=""

    ## Get credentials
    auth = {"vaultId": vaultId,
            "token": vaultToken,
            "world": serverlist[idx]["UUID"]}

    try:
        r=vault.post(VAULT_HOST+"/world_pass", json=auth, timeout=15.0)
        ## We got the access credentials
        if r.status_code == 200:
            auth2=r.json()
            PWD=" -U %s -P %s" % (auth2["user"], auth2["pass"])
        ## We were refused by Vault
        elif r.status_code == 403:
            stdout("Warning: Connection was refused (Vault logout?)")
            return -1
        ## We are rate-limited, try again
        elif r.status_code == 429:
            stdout("Rate limited!")
            return -1
        ## Internal error, maybe?
        else:
            stdout("Get World Auth - Returned error code %d" % r.status_code)
            return -1
    except:
        traceback.print_exc()
        return -1

    ## Using system-wide versus local path
    if pref["local"]:
        CMD=os.getcwd()+"/manaplus/"
        CMD=CMD.replace(" ", "\\ ")
        CWD=CMD[:-1]

    ## TODO: Handle server type and client
    if not sys.platform.startswith('win'):
        if pref["plus"]:
            CMD+="manaplus"
        elif pref["mana"] and "tmwa" in serverlist[idx]["Type"]:
            CMD+="mana"
        else:
            CMD+="ManaVerse"
        if pref["local"]:
            CMD+=".AppImage"
    else:
        ## Mana and M+ are not available on Windows (TODO)
        if pref["local"]:
            CMD+="Mana/manaplus.exe" # FIXME untested
        else:
            CMD+="manaplus.exe"      # FIXME untested

    ## Build the server options
    OPT="-s %s -y %s -p %s -S" % (HOST, serverlist[idx]["Type"], PORT)
    print("%s %s" % (CMD, OPT))
    ## Local or System-Wide Config folders
    if pref["local"]:
        if not sys.platform.startswith('win'):
            OPT+=" -C %s/Config -L %s/Local" % (CWD, CWD)
        else:
            OPT+=" -C %s\\Config -L %s\\Local" % (CWD.replace('/','\\'), CWD.replace('/','\\'))
        pass

    ## Execute the app
    ## TODO: Threading, MLP
    if pref["shell"]:
        app=execute(san("%s %s%s" % (CMD, OPT, PWD)), shell=True) # nosec
    else:
        app=execute(san("%s %s%s" % (CMD, OPT, PWD)), shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) # nosec

    return app

## Only meaningful on Linux
os.environ["APPIMAGELAUNCHER_DISABLE"]="1"

#################################################################################
## Construct session data
update_serverlist(SERVERLIST)
vaultId = -1
saveMail = None
savePass = None

#################################################################################
###### World Selection Screen
def world_select():
    global canva, serverlist
    canva = tk.Canvas(root, width=400, height=600, bg="#0c3251")
    canva.pack()
    label1 = tk.Label(root, text='World Selection', bg="#0c3251", fg="#fff")
    label1.config(font=('helvetica', 14))
    canva.create_window(200, 20, window=label1)

    # Not really necessary? Or just TODO?
    #scrollbar = tk.Scrollbar(canva, orient=tk.VERTICAL)
    #canva.create_window(400, 20, window=scrollbar)

    ## Create a list of all worlds
    ypos = 60
    for w in serverlist:
        ## TODO: Do not block main thread, launch this in a threading
        ## TODO: Make the button width fixed, so they align better
        ## TODO: Image button if an icon can be found
        button = HoverButton(text=w["Name"], command=partial(launch_game, serverlist.index(w)), width=300, bg="#cc6600", fg="#fff", activebackground="#ee9933")
        ## TODO: Handle MLP (on the threading?)
        ## TODO: First login greeting?
        canva.create_window(100, ypos, window=button)
        ## TODO: World Info Button
        button = HoverButton(text="?", command=partial(info_game, serverlist.index(w)), bg="#cc6600", fg="#fff", activebackground="#ee9933")
        canva.create_window(350, ypos, window=button)
        ypos += 40

    return

#################################################################################
## Login function
def login():
    global canva, vaultId, vaultToken, saveMail, savePass
    email = entry1.get()
    passw = entry2.get()
    totps = entry3.get()

    ## Save email or delete saved copy
    if saveMail.get():
        pref["user"] = email
    else:
        pref["user"] = ""

    ## Save password or delete saved copy
    if savePass.get():
        pref["pass"] = passw
    else:
        pref["pass"] = ""

    ## Prepare the packet for Vault
    data = {"mail": email,
              "pass": passw,
              "totp": totps[:6]
             }
    try:
        r = vault.post(VAULT_HOST+"/user_auth", json=data)

        if (r.status_code != 200):
            add=""
            if r.status_code == 400:
                add="\nBad request.\n"
            elif r.status_code == 401:
                add="\nIncorrect username/password.\n"
            elif r.status_code == 429:
                add="\nYou are being rate-limited.\n"
            showerror(title="Mana Launcher", message="Vault returned error %d\n%s\nPlease try again later." % (r.status_code, add))
            exit(1)
    except SystemExit:
            exit(1)
    except:
        err = traceback.format_exc().split("\n")[-2]
        print("Error: %s" % err)
        showerror(title="Mana Launcher", message="Python error\n\nPlease try again later.")
        exit(1)

    auth2 = r.json()
    vaultId = auth2["vaultId"]
    vaultToken = auth2["token"]

    ## Only save settings if connection worked
    _savePref()

    ## Change the display
    print("Connected to vault! vaultId = %d" % vaultId)
    canva.destroy()
    ## TODO: Do not jump to world selection if it is a new account
    world_select()
    return

#################################################################################
## Build Tkinter interface
root=tk.Tk()
root.title("Mana Launcher (tk)")
canva = tk.Canvas(root, width=400, height=600, bg="#0c3251")
canva.pack()

## Default variables
saveMail = tk.BooleanVar()
saveMail.set(pref["user"] != "")
savePass = tk.BooleanVar()
savePass.set(pref["pass"] != "")

## Email
label1 = tk.Label(root, text='Email:', bg="#0c3251", fg="#fff")
label1.config(font=('helvetica', 14))
canva.create_window(200, 40, window=label1)
entry1 = tk.Entry(root)
entry1.insert(0, pref["user"])
canva.create_window(200, 80, window=entry1)
c1 = tk.Checkbutton(root, text="Remember", variable=saveMail, bg="#0c3251", fg="#f70")
canva.create_window(350, 80, window=c1)


label2 = tk.Label(root, text='Password:', bg="#0c3251", fg="#fff")
label2.config(font=('helvetica', 14))
canva.create_window(200, 120, window=label2)
entry2 = tk.Entry(root, show="*")
entry2.insert(0, pref["pass"])
canva.create_window(200, 160, window=entry2)
c1 = tk.Checkbutton(root, text="Remember", variable=savePass, bg="#0c3251", fg="#f70")
canva.create_window(350, 160, window=c1)


label3 = tk.Label(root, text='TOTP:', bg="#0c3251", fg="#fff")
label3.config(font=('helvetica', 14))
canva.create_window(200, 200, window=label3)
entry3 = tk.Entry(root)
#entry3.insert(0, pref["totp"])
canva.create_window(200, 240, window=entry3)


button1 = HoverButton(text='Login', command=login, bg="#cc6600", fg="#fff", activebackground="#ee9933")
canva.create_window(200, 300, window=button1)
root.mainloop()

# Check if you're now logged in
if vaultId < 1:
    exit(1)

print("Thanks for playing!")

exit(0) # <- FIXME: Not necessary? Just delete stuff below

#################################################################################
"""
    while True:
        app=launch_game(idx)

        ## TODO: Handle MLP
        while app == 7:
            stdout("[CLIENT] Mirror Lake triggered.")
            r=vault.post(VAULT_HOST+"/getlake", json=auth, timeout=15.0)
            if r.status_code == 200:
                goto=r.json()["world"]
                stdout("MLP Target: %s" % str(goto))
                if goto == "" or goto.lower() == "vault":
                    stdout("Mirror Lake False Positive")
                    break
                try:
                    idx=int(dl_search_idx(serverlist, "UUID", goto))
                    app=launch_game(idx)
                except:
                    traceback.print_exc()
                    break
        else:
            stdout("ERROR: Unknown command. Try \"help\".")
"""