diff --git a/requirements.txt b/requirements.txt index b08f91e..b19ebe4 100755 --- a/requirements.txt +++ b/requirements.txt @@ -50,13 +50,14 @@ keyrings.cryptfile==1.3.9 Kivy==2.2.1 Kivy-Garden==0.1.5 kivy-garden.mapview==1.0.6 -kivymd==1.1.1 +kivymd==1.2.0 kivyoav==0.42 macholib==1.16.3 mapview==1.0.6 mnemonic==0.21 more-itertools==10.2.0 mospy-wallet==0.6.0 +packaging==26.0 pexpect==4.8.0 Pillow==9.2.0 proto-plus==1.26.1 @@ -86,7 +87,7 @@ safe-pysha3==1.0.4 screeninfo==0.8.1 SecretStorage==3.3.3 sentinel-sdk==0.1.1 -sentinel_protobuf==0.5.0 +sentinel_protobuf==0.5.1 six==1.16.0 sniffio==1.3.1 stripe==4.2.0 diff --git a/src/bin/routes.sh b/src/bin/routes.sh index 89de0e9..1b3a9eb 100755 --- a/src/bin/routes.sh +++ b/src/bin/routes.sh @@ -26,7 +26,7 @@ if [[ ${STATE} = "up" ]]; then # get v2ray proxy IP PROXY_IP=`cat /home/${USER}/.meile-gui/v2ray.proxy` #echo ${PROXY_IP} > /home/${USER}/.meile-gui/v2ray.proxy - echo ${PROXY_IP} + echo "Proxy IP: ${PROXY_IP}" sleep 2 # add tun interface diff --git a/src/cli/v2ray.py b/src/cli/v2ray.py index 582e7ad..dd5cf8f 100755 --- a/src/cli/v2ray.py +++ b/src/cli/v2ray.py @@ -1,48 +1,428 @@ -from subprocess import Popen +import subprocess +from subprocess import Popen, PIPE import multiprocessing from multiprocessing import Process from time import sleep from dataclasses import dataclass -import psutil +import sys +import os -from typedef.konstants import ConfParams from conf.meile_config import MeileGuiConfig -class V2RayHandler(): +# Platform-conditional imports +if sys.platform == 'win32': + import psutil + import netifaces + import json + from os import path + import win32gui, win32con + from typedef.konstants import ConfParams +elif sys.platform == 'darwin': + import tempfile +elif sys.platform.startswith('linux'): + import psutil + from typedef.konstants import ConfParams + +# --------------------------------------------------------------------------- +# V2RayHandler – one class per platform, selected at the bottom of this +# section via V2RayHandler = _LinuxV2RayHandler | _WindowsV2RayHandler | … +# --------------------------------------------------------------------------- + +class _LinuxV2RayHandler(): v2ray_script = None v2ray_pid = None - + def __init__(self, script, **kwargs): self.v2ray_script = script print(f"v2ray_script: {self.v2ray_script}") print(self.v2ray_script) - + def fork_v2ray(self): - v2ray_daemon_cmd = 'pkexec env PATH=%s %s' %(ConfParams.PATH, self.v2ray_script) - v2ray_srvc_proc = Popen(v2ray_daemon_cmd, shell=True,close_fds=True) - + v2ray_daemon_cmd = ( + 'pkexec env PATH=%s %s' + % (ConfParams.PATH, self.v2ray_script) + ) + v2ray_srvc_proc = Popen( + v2ray_daemon_cmd, shell=True, close_fds=True + ) + print("PID: %s" % v2ray_srvc_proc.pid) - + self.v2ray_pid = v2ray_srvc_proc.pid - def start_daemon(self): - + print("Starting v2ray service...") - + multiprocessing.get_context('fork') warp_fork = Process(target=self.fork_v2ray) warp_fork.run() sleep(1.5) return True - + + def kill_daemon(self): + v2ray_daemon_cmd = ( + 'pkexec env PATH=%s %s' + % (ConfParams.PATH, self.v2ray_script) + ) + proc2 = Popen(v2ray_daemon_cmd, shell=True) + proc2.wait(timeout=30) + proc_out, proc_err = proc2.communicate() + return proc2.returncode + +class _WindowsV2RayHandler(): + MeileConfig = MeileGuiConfig() + v2ray_script = None + v2ray_pid = None + tunproc = "tun2socks.exe" + v2rayproc = "xray.exe" + CREATE_NO_WINDOW = 0x08000000 + CREATE_NEW_CONSOLE = 0x00000010 + WINDOW_TITLE = "meile_v2ray_daemon" + + def __init__(self, script, **kwargs): + self.v2ray_script = script + print(self.v2ray_script) + + def fork_v2ray(self): + # Use "title" command so we can find the window + # reliably by its exact title + v2ray_daemon_cmd = ( + 'cmd.exe /c start "%s" cmd.exe /k gsudo.exe %s' + % (self.WINDOW_TITLE, self.v2ray_script) + ) + v2ray_srvc_proc = Popen( + v2ray_daemon_cmd, + shell=True, + stdout=PIPE, + stderr=PIPE + ) + sleep(5) + print("PID: %s" % v2ray_srvc_proc.pid) + + # Find and hide the window by our known title + hwnd = self._find_window_by_title(self.WINDOW_TITLE) + if hwnd: + print("Found window hwnd=%s, hiding..." % hwnd) + win32gui.ShowWindow(hwnd, win32con.SW_HIDE) + else: + print("WARNING: Could not find cmd window to hide") + + self.v2ray_pid = v2ray_srvc_proc.pid + + def _find_window_by_title(self, title): + """ + Enumerate all windows and find the one whose + title contains our unique identifier. + """ + result = [] + + def callback(hwnd, _): + window_title = win32gui.GetWindowText(hwnd) + if title in window_title: + result.append(hwnd) + return True + + win32gui.EnumWindows(callback, None) + + if result: + return result[0] + return None + + def start_daemon(self): + + print("Starting v2ray service...") + + routes_bat = 'routes.bat' + gateways = netifaces.gateways() + + default_gateway = gateways[netifaces.AF_INET][0][0] + + SERVER = self.read_v2ray_config() + + batfile = open(routes_bat, 'w') + + batfile.write( + 'CD "%s"\n' % self.MeileConfig.BASEBINDIR + ) + batfile.write( + 'START "" /B %s run -c %s\n' + % ( + self.v2rayproc, + path.join( + self.MeileConfig.BASEDIR, + "v2ray_config.json" + ), + ) + ) + batfile.write('timeout /t 1\n') + batfile.write( + 'START "" /B %s -device tun://tun00' + ' -proxy socks5://127.0.0.1:1080"\n' + % self.tunproc + ) + batfile.write('timeout /t 2\n') + batfile.write( + 'netsh interface ip set address "tun00"' + ' static address=10.10.10.2' + ' mask=255.255.255.0 gateway=10.10.10.1\n' + ) + batfile.write( + 'netsh interface ip set dns name="tun00"' + ' static 1.1.1.1\n' + ) + batfile.write( + 'route add %s %s metric 5\n' + % (SERVER, default_gateway) + ) + batfile.write( + 'route add 0.0.0.0 mask 0.0.0.0 10.10.10.1' + ) + batfile.flush() + batfile.close() + + self.v2ray_script = routes_bat + + self.fork_v2ray() + sleep(3) + + return True + def kill_daemon(self): - v2ray_daemon_cmd = 'pkexec env PATH=%s %s' %(ConfParams.PATH, self.v2ray_script) + + SERVER = self.read_v2ray_config() + gateways = netifaces.gateways() + default_gateway = gateways[netifaces.AF_INET][0][0] + + routes_bat = 'delroutes.bat' + + batfile = open(routes_bat, 'w') + + batfile.write( + 'route delete %s %s metric 5\n' + % (SERVER, default_gateway) + ) + batfile.write( + 'route delete 0.0.0.0 mask 0.0.0.0 10.10.10.1\n' + ) + batfile.write( + 'netsh interface set interface' + ' name="tun00" disable\n' + ) + batfile.write('timeout /t 3\n') + batfile.write('TASKKILL /F /IM tun2socks.exe\n') + batfile.write('TASKKILL /F /IM xray.exe\n') + batfile.flush() + batfile.close() + + self.v2ray_script = routes_bat + + # Use our known title so we can find/kill it + v2ray_daemon_cmd = ( + 'gsudo.exe %s' % (self.v2ray_script) + ) proc2 = Popen(v2ray_daemon_cmd, shell=True) proc2.wait(timeout=30) - proc_out,proc_err = proc2.communicate() + proc_out, proc_err = proc2.communicate() + + # Kill the hidden cmd.exe window we spawned + hwnd = self._find_window_by_title(self.WINDOW_TITLE) + if hwnd: + win32gui.PostMessage( + hwnd, win32con.WM_CLOSE, 0, 0 + ) + return proc2.returncode - + + def read_v2ray_config(self): + + with open( + path.join( + self.MeileConfig.BASEDIR, 'v2ray_config.json' + ), + 'r', + ) as V2RAYFILE: + v2ray = V2RAYFILE.read() + + JSON = json.loads(v2ray) + + return ( + JSON['outbounds'][0]['settings']['vnext'][0]['address'] + ) + +class _DarwinV2RayHandler: + v2ray_pid = 0 + + def __init__(self, script, **kwargs): + self.script_path = script + self.processes = [] + self.MeileConfig = MeileGuiConfig() + + def run_privileged_script(self, commands): + script_content = "#!/bin/bash\n" + script_content += "\n".join(commands) + + with tempfile.NamedTemporaryFile( + mode='w', suffix='.sh', delete=False + ) as f: + f.write(script_content) + temp_script_path = f.name + + os.chmod(temp_script_path, 0o755) + + applescript = f''' + tell application "System Events" + do shell script "{temp_script_path}" with administrator privileges + end tell + ''' + + try: + proc = subprocess.Popen( + ['osascript', '-e', applescript], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + stdout, stderr = proc.communicate(timeout=120) + + os.unlink(temp_script_path) + + if proc.returncode == 0: + return True + else: + print( + "Privileged script failed with" + " return code %s" % proc.returncode + ) + print(f"STDERR: {stderr}") + return False + + except subprocess.TimeoutExpired: + print("Privileged execution timed out") + try: + proc.kill() + except: + pass + os.unlink(temp_script_path) + return False + except Exception as e: + print(f"Error running privileged script: {e}") + os.unlink(temp_script_path) + return False + + def run_cmd(self, cmd, background=False): + if background: + process = subprocess.Popen( + cmd, + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return process + else: + return subprocess.run(cmd, shell=True, timeout=30) + + def start_daemon(self): + print("Starting v2ray service...") + + privileged_commands = [ + "launchctl bootstrap system" + " /Library/LaunchDaemons/app.meile.xray.plist" + ] + privileged_commands.append("sleep 3") + privileged_commands.append( + "curl --preproxy socks5://localhost:1080" + " -s https://icanhazip.com" + ) + privileged_commands.append("sleep 1") + privileged_commands.append( + "launchctl bootstrap system" + " /Library/LaunchDaemons/app.meile.tun2socks.plist" + ) + privileged_commands.append("sleep 2") + privileged_commands.append( + "ifconfig utun123 198.18.0.1 198.18.0.1 up" + ) + + networks = [ + "1.0.0.0/8", + "2.0.0.0/7", + "4.0.0.0/6", + "8.0.0.0/5", + "16.0.0.0/4", + "32.0.0.0/3", + "64.0.0.0/2", + "128.0.0.0/1", + "198.18.0.0/15", + ] + + for network in networks: + privileged_commands.append( + f"route add -net {network} 198.18.0.1" + ) + + if not self.run_privileged_script(privileged_commands): + print("Failed to execute privileged commands") + return False + + return True + + def kill_daemon(self): + privileged_commands = [] + + networks = [ + "1.0.0.0/8", + "2.0.0.0/7", + "4.0.0.0/6", + "8.0.0.0/5", + "16.0.0.0/4", + "32.0.0.0/3", + "64.0.0.0/2", + "128.0.0.0/1", + "198.18.0.0/15", + ] + + for network in networks: + privileged_commands.append( + f"route delete -net {network} 198.18.0.1" + ) + + privileged_commands.append( + "ifconfig utun123 198.18.0.1 198.18.0.1 down" + ) + privileged_commands.append( + "launchctl bootout system" + " /Library/LaunchDaemons/app.meile.xray.plist" + " ; launchctl bootout system" + " /Library/LaunchDaemons/app.meile.tun2socks.plist" + ) + + self.run_privileged_script(privileged_commands) + + for proc in self.processes: + proc.terminate() + + return True + +# --------------------------------------------------------------------------- +# Select the correct handler for the current platform +# --------------------------------------------------------------------------- +if sys.platform.startswith('linux'): + V2RayHandler = _LinuxV2RayHandler +elif sys.platform == 'win32': + V2RayHandler = _WindowsV2RayHandler +elif sys.platform == 'darwin': + V2RayHandler = _DarwinV2RayHandler +else: + raise RuntimeError( + f"Unsupported platform: {sys.platform}" + ) + +# --------------------------------------------------------------------------- +# Configuration dataclasses – identical across all three platforms +# --------------------------------------------------------------------------- + @dataclass class V2RayFragmentConfiguration: api_port: int @@ -91,111 +471,109 @@ def get(self) -> dict: "tag": "proxy" } ], - "log": { - "loglevel": "none" - }, - "outbounds": [ - { - "mux": { - "concurrency": -1, - "enabled": False - }, - "protocol": self.proxy_protocol, - "settings": { - "vnext": [ - { - "address": self.vmess_address, - "port": self.vmess_port, - "users": [ - { - "alterId": 0, - "id": self.vmess_uid, - "level" : 8, - "security": "chacha20-poly1305" - } - ] - } - ] - }, - "streamSettings": { - "grpcSettings": { - "authority": "", - "health_check_timeout": 20, - "idle_timeout": 60, - "multiMode": False, - "serviceName": "" - }, - "network": self.vmess_transport, - "sockopt": { - "dialerProxy": "fragment", - "tcpKeepAliveIdle": 100, - "tcpNoDelay": True - } - }, - "tag": "vmess" + "log": { + "loglevel": "none" }, - { - "tag": "fragment", - "protocol": "freedom", - "settings": { - "domainStrategy": "AsIs", - "fragment": { - "packets": "1-3", - "length": "1-3", - "interval": "2-8" + "outbounds": [ + { + "mux": { + "concurrency": -1, + "enabled": False + }, + "protocol": self.proxy_protocol, + "settings": { + "vnext": [ + { + "address": self.vmess_address, + "port": self.vmess_port, + "users": [ + { + "alterId": 0, + "id": self.vmess_uid, + "level": 8, + "security": + "chacha20-poly1305" + } + ] + } + ] + }, + "streamSettings": { + "grpcSettings": { + "authority": "", + "health_check_timeout": 20, + "idle_timeout": 60, + "multiMode": False, + "serviceName": "" + }, + "network": self.vmess_transport, + "sockopt": { + "dialerProxy": "fragment", + "tcpKeepAliveIdle": 100, + "tcpNoDelay": True + } + }, + "tag": "vmess" + }, + { + "tag": "fragment", + "protocol": "freedom", + "settings": { + "domainStrategy": "AsIs", + "fragment": { + "packets": "1-3", + "length": "1-3", + "interval": "2-8" + } + }, + "streamSettings": { + "sockopt": { + "tcpKeepAliveIdle": 100, + "tcpNoDelay": True + } + } + }, + { + "protocol": "freedom", + "settings": { + "domainStrategy": "UseIP" + }, + "tag": "direct" + }, + { + "protocol": "blackhole", + "settings": { + "response": { + "type": "http" + } + }, + "tag": "block" } - }, - "streamSettings": { - "sockopt": { - "tcpKeepAliveIdle": 100, - "tcpNoDelay": True + ], + "policy": { + "levels": { + "0": { + "downlinkOnly": 0, + "uplinkOnly": 0 + } + }, + "system": { + "statsOutboundDownlink": True, + "statsOutboundUplink": True } - } }, - { - "protocol": "freedom", - "settings": { - "domainStrategy": "UseIP" - }, - "tag": "direct" - }, - { - "protocol": "blackhole", - "settings": { - "response": { - "type": "http" - } - }, - "tag": "block" - } - ], - "policy": { - "levels": { - "0": { - "downlinkOnly": 0, - "uplinkOnly": 0 - } + "routing": { + "rules": [ + { + "inboundTag": ["api"], + "outboundTag": "api", + "type": "field" + } + ] }, - "system": { - "statsOutboundDownlink": True, - "statsOutboundUplink": True - } - }, - "routing": { - "rules": [ - { - "inboundTag": ["api"], - "outboundTag": "api", - "type": "field" - } - ] - }, - "stats": {} + "stats": {} } - - - @dataclass class V2RayConfiguration: api_port: int diff --git a/src/cli/wallet.py b/src/cli/wallet.py index bacec74..618eeba 100644 --- a/src/cli/wallet.py +++ b/src/cli/wallet.py @@ -7,7 +7,7 @@ import re import platform from time import sleep -from os import path, remove +from os import path, remove, chdir from urllib.parse import urlparse from urllib3.exceptions import NewConnectionError from grpc import RpcError, StatusCode @@ -482,22 +482,20 @@ def subscribe(self, KEYNAME, NODE, DEPOSIT, GB, hourly): amount_required = float(DEPOSIT.replace(DENOM, "")) * IBCTokens.SATOSHI if DENOM == "udvpn": - tax = round(float(amount_required * ConfParams.SUBFEE),2) if round(float(amount_required * ConfParams.SUBFEE),2) >= 5 * IBCTokens.SATOSHI else 5 * IBCTokens.SATOSHI + tax = round(float(amount_required * ConfParams.SUBFEE),2) if round(float(amount_required * ConfParams.SUBFEE),2) >= ConfParams.SUBFEEMULT * IBCTokens.SATOSHI else ConfParams.SUBFEEMULT * IBCTokens.SATOSHI else: tax = round(float(amount_required * ConfParams.SUBFEE),2) try: + token_ibc = {v: k for k, v in IBCTokens.IBCUNITTOKEN.items()} + ubalance = balance.get(token_ibc[DENOM][1:], 0) * IBCTokens.SATOSHI + if amount_required + tax >= (ubalance + IBCTokens.SATOSHI): + self.returncode = (False, f"Balance is too low, required: {round((amount_required + tax) / IBCTokens.SATOSHI, 6)}{token_ibc[DENOM][1:]}") + return ret = self.send_2plan_wallet(KEYNAME, 31337, DENOM, tax, tax=True, bal=balance) print(ret[0]) except: pass - token_ibc = {v: k for k, v in IBCTokens.IBCUNITTOKEN.items()} - ubalance = balance.get(token_ibc[DENOM][1:], 0) * IBCTokens.SATOSHI - - if ubalance < amount_required: - self.returncode = (False, f"Balance is too low, required: {round(amount_required / IBCTokens.SATOSHI, 4)}{token_ibc[DENOM][1:]}") - return - try: result = sdk.nodes.QueryNode(address=NODE) except (mospy.exceptions.clients.TransactionTimeout, @@ -652,8 +650,234 @@ def get_random_node_addresses(self, node_tree: Tree, count: int = 8) -> list: addresses.append(node.data['Address']) print(f"Nodes for ring sessions: {addresses}") - return addresses + return addresses + + def fetch_credentials(self, address, session_id, type, conndesc, renew_sdk: bool = False): + if renew_sdk: + CONFIG = MeileConfig.read_configuration(MeileConfig.CONFFILE) + PASSWORD = CONFIG['wallet'].get('password', '') + KEYNAME = CONFIG['wallet'].get('keyname', '') + self.GRPC = CONFIG['network'].get('grpc', HTTParams.GRPC) + grpcaddr, grpcport = self.GRPC.split(":") + kr = self.__keyring(PASSWORD) + private_key = kr.get_password("meile-gui", KEYNAME) + try: + self.sdk = SDKInstance(grpcaddr, int(grpcport), secret=private_key, ssl=True) + except ConnectionError: + message = "gRPC unresponsive. Try again later or switch gRPCs." + self.connected = {"v2ray_pid" : None, + "result" : False, + "status" : message, + "session_id" : None} + return + except grpc._channel._InactiveRpcError as e: + status_code = e.code() + + if status_code == StatusCode.NOT_FOUND: + message = "Wallet not found on blockchain. Please verify you have sent coins to your wallet to activate it. Then try your subscription again" + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : message, + "session_id" : None} + print(self.connected) + return + else: + message = "gRPC Error!" + self.connected = {"v2ray_pid" : None, + "result" : False, + "status" : message, + "session_id" : None} + return + + + node = self.sdk.nodes.QueryNode(address) + + for _ in range(0, 10): + pub_key_bytes = self.sdk._account.public_key.key + pub_key_b64 = base64.b64encode(pub_key_bytes).decode("utf-8") + pub_key = f"secp256k1:{pub_key_b64}" + + if type == "WireGuard": + wgkey = WgKey() + key = wgkey.pubkey + data = {'public_key': key} + data_bytes = json.dumps(data).encode('utf-8') + else: # NodeType.V2RAY + self.uid_16 = uuid.uuid4() + data = {'uuid': list(self.uid_16.bytes)} + data_bytes = json.dumps(data).encode('utf-8') + + sk = ecdsa.SigningKey.from_string( + self.sdk._account.private_key, + curve=ecdsa.SECP256k1, + hashfunc=hashlib.sha256 + ) + + bige_session = int(session_id).to_bytes(8, byteorder="big") + msg = bige_session + data_bytes + signature = sk.sign(msg) + + payload = { + "data": base64.b64encode(data_bytes).decode("utf-8"), + "id": int(session_id), + "pub_key": pub_key, + "signature": base64.b64encode(signature).decode("utf-8"), + } + + print(f"\nPayload: {json.dumps(payload, indent=4)}") + conndesc.write("Fetching credentials from node...\n") + conndesc.flush() + try: + response = requests.post( + f"https://{node.remote_addrs[0]}/", + json=payload, + headers={ + "Content-Type": "application/json; charset=utf-8" + }, + verify=False, + timeout=17 + ) + except ( + ReadTimeout, ConnectionError, ConnectionRefusedError + ) as e: + print(str(e)) + status = ("Timeout while trying to fetch credentials " + "from node...Exiting\n") + conndesc.write(status) + conndesc.flush() + conndesc.close() + self.connected = { + "v2ray_pid": None, + "result": False, + "status": status + } + print(self.connected) + return None, None, None + except NewConnectionError as e: + print(str(e)) + status = ("Timeout while trying to fetch credentials " + "from node...Exiting\n") + conndesc.write(status) + conndesc.flush() + conndesc.close() + self.connected = { + "v2ray_pid": None, + "result": False, + "status": status + } + print(self.connected) + return None, None, None + + print(response, response.text) + + if response.ok is True: + break + + sleep(random.uniform(0.5, 1)) + # Continue iteration only for code == 4 (invalid signature) + if response.json()["error"]["code"] != 2: + break + + if response.ok is False: + self.connected = { + "v2ray_pid": None, + "result": False, + "status": response.text + } + print(self.connected) + return None, None, None + + response = response.json() + if response.get("success", True) is True: + response_dict = response["result"] + decode = base64.b64decode( + response_dict['data'] + ).decode('utf-8') + print(f"\nDecode: {decode}") + print(f"\nlength: {len(decode)}") + + wgkey_out = wgkey if type == "WireGuard" else None + return response, decode, wgkey_out + + return None, None, None + + def write_wireguard_config(self, response, decode, wgkey, conndesc, iface): + if len(decode) < 100: + self.connected = { + "v2ray_pid": None, + "result": False, + "status": f"Incorrect result size: {len(decode)}" + } + print(self.connected) + return None + + decode = json.loads(decode) + conndesc.write("Bringing up dVPN tunnel...\n") + conndesc.flush() + + ipv4_address = decode['addrs'][0] + if len(decode['addrs']) > 1: + ipv6_address = decode['addrs'][1] + else: + ipv6_address = "" + + # Here should check if client is using ipv6. + # This will give ipv4 + host = response['result']['addrs'][0] + port = decode['metadata'][0]['port'] + peer_endpoint = f"{host}:{port}" + + print("ipv4_address", ipv4_address) + print("ipv6_address", ipv6_address) + print("host", host) + print("port", port) + print("peer_endpoint", peer_endpoint) + + public_key = decode['metadata'][0]['public_key'] + print("public_key", public_key) + + config = configparser.ConfigParser() + config.optionxform = str + + # [from golang] listenPort, err := netutil.GetFreeUDPPort() + sock = socket.socket() + sock.bind(('', 0)) + listen_port = sock.getsockname()[1] + sock.close() + + address = ( + ",".join([ipv4_address, ipv6_address]) + if ipv6_address + else ipv4_address + ) + + config.add_section("Interface") + config.set("Interface", "Address", address) + config.set("Interface", "ListenPort", f"{listen_port}") + config.set("Interface", "PrivateKey", wgkey.privkey) + config.set( + "Interface", "DNS", + ",".join(["127.0.0.1", "1.0.0.1", "1.1.1.1"]) + ) + + config.add_section("Peer") + config.set("Peer", "PublicKey", public_key) + config.set("Peer", "Endpoint", peer_endpoint) + config.set("Peer", "AllowedIPs", ",".join(["0.0.0.0/0", "::/0"])) + config.set("Peer", "PersistentKeepalive", "25") + + + config_file = path.join(ConfParams.KEYRINGDIR, f"{iface}.conf") + + if path.isfile(config_file) is True: + remove(config_file) + + with open(config_file, "w", encoding="utf-8") as f: + config.write(f) + + return config_file + def connect(self, ID, address, @@ -689,24 +913,33 @@ def connect(self, private_key = kr.get_password("meile-gui", KEYNAME) try: - sdk = SDKInstance(grpcaddr, int(grpcport), secret=private_key, ssl=True) + self.sdk = SDKInstance(grpcaddr, int(grpcport), secret=private_key, ssl=True) except ConnectionError: message = "gRPC unresponsive. Try again later or switch gRPCs." - self.connected = {"v2ray_pid" : None, "result" : False, "status" : message} + self.connected = {"v2ray_pid" : None, + "result" : False, + "status" : message, + "session_id" : None} return except grpc._channel._InactiveRpcError as e: status_code = e.code() if status_code == StatusCode.NOT_FOUND: message = "Wallet not found on blockchain. Please verify you have sent coins to your wallet to activate it. Then try your subscription again" - self.connected = {"v2ray_pid" : None, "result": False, "status" : message} + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : message, + "session_id" : None} print(self.connected) return else: message = "gRPC Error!" - self.connected = {"v2ray_pid" : None, "result" : False, "status" : message} + self.connected = {"v2ray_pid" : None, + "result" : False, + "status" : message, + "session_id" : None} return - + regex_denom = r'^([\d\.]+)(.*)$' regres = re.match(regex_denom, deposit) @@ -747,7 +980,7 @@ def connect(self, if plan: try: if not RINGSESSIONS: - tx = sdk.subscriptions.StartSession(subscription_id=int(ID), address=address, tx_params=tx_params) + tx = self.sdk.subscriptions.StartSession(subscription_id=int(ID), address=address, tx_params=tx_params) else: addresses = self.get_random_node_addresses(NodeTree, count=8) rand_index = random.randint(0, len(addresses)) @@ -758,7 +991,7 @@ def connect(self, conndesc.flush() if addr == address: - tx = sdk.subscriptions.StartSession( + tx = self.sdk.subscriptions.StartSession( subscription_id=int(ID), address=addr, next_sequence = True if k > 1 else False, @@ -766,7 +999,7 @@ def connect(self, ) print(tx) else: - tx_temp = sdk.subscriptions.StartSession( + tx_temp = self.sdk.subscriptions.StartSession( subscription_id=int(ID), address=addr, next_sequence = True if k > 1 else False, @@ -774,7 +1007,7 @@ def connect(self, ) print(tx_temp) - print(f"Sequence after tx: {sdk.subscriptions._account.next_sequence}") + print(f"Sequence after tx: {self.sdk.subscriptions._account.next_sequence}") sleep(0.1) k += 1 @@ -786,7 +1019,10 @@ def connect(self, conndesc.write("GRPC Error... Exiting") conndesc.flush() conndesc.close() - self.connected = {"v2ray_pid" : None, "result": False, "status" : details} + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : details, + "session_id" : None} print(self.connected) return else: @@ -797,7 +1033,7 @@ def connect(self, try: - tx = sdk.nodes.SubscribeToNode(node_address=address, + tx = self.sdk.nodes.SubscribeToNode(node_address=address, price=sprice, gigabytes=0 if hourly else int(units), hours=int(units) if hourly else 0, @@ -810,18 +1046,24 @@ def connect(self, conndesc.write("GRPC Error... Exiting") conndesc.flush() conndesc.close() - self.connected = {"v2ray_pid" : None, "result": False, "status" : details} + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : details, + "session_id" : None} print(self.connected) return # Will need to handle log responses with friendly UI response in case of session create error if tx.get("log", None) is not None: - self.connected = {"v2ray_pid" : None, "result": False, "status" : tx["log"]} + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : tx["log"], + "session_id" : None} print(self.connected) return try: - tx_response = sdk.subscriptions.wait_for_tx(tx["hash"], timeout=25) + tx_response = self.sdk.subscriptions.wait_for_tx(tx["hash"], timeout=25) except (mospy.exceptions.clients.TransactionTimeout, mospy.exceptions.clients.NodeException, mospy.exceptions.clients.NodeTimeoutException) as e: @@ -829,16 +1071,30 @@ def connect(self, conndesc.write("GRPC Error... Exiting") conndesc.flush() conndesc.close() - self.connected = {"v2ray_pid" : None, "result": False, "status" : "GRPC Error"} + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : "GRPC Error", + "session_id" : None} return session_id = search_attribute(tx_response, "sentinel.subscription.v3.EventCreateSession" if plan else "sentinel.node.v3.EventCreateSession", "session_id") + + ''' + from_event = { + "subscription_id": search_attribute(tx_response, "sentinel.subscription.v3.EventCreateSession", "subscription_id"), + "address": search_attribute(tx_response, "sentinel.subscription.v3.EventCreateSession", "acc_address"), + "node_address": search_attribute(tx_response, "sentinel.subscription.v3.EventCreateSession", "node_address"), + } + ''' - sleep(0.3) # Wait a few seconds.... + # Sanity Check. Not needed + #assert from_event["subscription_id"] == ID and from_event["address"] == sdk._account.address and from_event["node_address"] == address + + sleep(0.2) # Wait a few seconds.... # The sleep is required because the session_id could not be fetched from the node / rpc - + ''' node = sdk.nodes.QueryNode(address) # Get Client PubKey @@ -982,147 +1238,186 @@ def connect(self, with open(config_file, "w", encoding="utf-8") as f: config.write(f) - - if pltfrm == Arch.LINUX: - child = pexpect.spawn(f"pkexec sh -c 'ip link delete {iface}; wg-quick up {config_file}'") - child.expect(pexpect.EOF) - sleep(7) - elif pltfrm == Arch.OSX: - connectBASH = [sentinel_connect_bash] - proc2 = subprocess.Popen(connectBASH) - proc2.wait(timeout=30) - pid2 = proc2.pid - proc_out, proc_err = proc2.communicate() - elif pltfrm == Arch.WINDOWS: - wgup = [gsudo, MeileConfig.WIREGUARD_BIN, "/installtunnelservice", config_file] - wg_process = subprocess.Popen(wgup) - sleep(15) - - if psutil.net_if_addrs().get(iface): - self.connected = {"v2ray_pid" : None, "result": True, "status" : iface} - conndesc.write("Checking network connection...\n") - conndesc.flush() - sleep(1) - self.get_ip_address() - sleep(1) - conndesc.close() - return - else: - self.connected = {"v2ray_pid" : None, "result": False, "status" : "Error bringing up wireguard interface"} - return + ''' + response, decode, wgkey = self.fetch_credentials( + address, session_id, type, conndesc + ) + + if response is None or decode is None: + status = "Node response is null. Terminating..." + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : status, + "session_id" : session_id} + print(self.connected) + return + + if type == "WireGuard": + iface = "wg99" + config_file = self.write_wireguard_config( + response, decode, wgkey, conndesc, iface + ) + + if config_file is None: + status = "Error writing WireGuard config. Terminating..." + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : status, + "session_id" : session_id} + print(self.connected) + return + + - else: # v2ray - # os x - #chdir(MeileConfig.BASEBINDIR) - conndesc.write("Bringing up V2Ray socks tunnel...\n") + if pltfrm == Arch.LINUX: + child = pexpect.spawn(f"pkexec sh -c 'ip link delete {iface}; wg-quick up {config_file}'") + child.expect(pexpect.EOF) + sleep(5) + elif pltfrm == Arch.OSX: + connectBASH = [sentinel_connect_bash] + proc2 = subprocess.Popen(connectBASH) + proc2.wait(timeout=30) + pid2 = proc2.pid + proc_out, proc_err = proc2.communicate() + #subprocess.run(["sudo", "launchctl", "load", str(LAUNCHDAEMON_PATH)], check=True) + sleep(3) + elif pltfrm == Arch.WINDOWS: + wgup = [gsudo, MeileConfig.WIREGUARD_BIN, "/installtunnelservice", config_file] + wg_process = subprocess.Popen(wgup) + sleep(15) + + if psutil.net_if_addrs().get(iface) or psutil.net_if_addrs().get("utun3"): + self.connected = {"v2ray_pid" : None, + "result": True, + "status" : iface, + "session_id" : session_id} + conndesc.write("Checking network connection...\n") conndesc.flush() - decode = json.loads(decode) - - proxy_protocol = ["vless", "vmess"] - transport_protocol = ["gun","grpc","http","mkcp","quic","tcp","websocket"] - #transport_security = ["none", "tls"] + sleep(1) + self.get_ip_address() + sleep(1) + conndesc.close() + return + else: + self.connected = {"v2ray_pid" : None, + "result": False, + "status" : "Error bringing up wireguard interface", + "session_id" : session_id} + return + + else: # v2ray + # os x + chdir(MeileConfig.BASEBINDIR) + conndesc.write("Bringing up V2Ray socks tunnel...\n") + conndesc.flush() + decode = json.loads(decode) + print(decode) + + proxy_protocol = ["vless", "vmess"] + transport_protocol = ["domainsocket","gun","grpc","http","mkcp","quic","tcp","websocket"] + #transport_security = ["none", "tls"] + + vmess_address = resolve_address(response['result']['addrs'][0]) + vmess_port = int(decode['metadata'][0]['port']) + pp = proxy_protocol[decode['metadata'][0]['proxy_protocol']-1] + tp = transport_protocol[decode['metadata'][0]['transport_protocol']-1] + #tp = transport_protocol[1] + #ts = transport_security[decode['metadata'][0]['transport_security']-1] + + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(('', 0)) + api_port = sock.getsockname()[1] + sock.close() + + print("api_port", api_port) + print("vmess_port", vmess_port) + print("vmess_address", vmess_address) + print("vmess_uid", f"{self.uid_16}") + print("vmess_transport", tp) + print("proxy_protocol", pp) + + if self.FRAGMENT: + v2ray_config = V2RayFragmentConfiguration( + api_port=api_port, + vmess_port=vmess_port, + vmess_address=vmess_address, + vmess_uid=f"{self.uid_16}", + vmess_transport=tp, + proxy_port=1080, + proxy_protocol=pp + ) + else: + v2ray_config = V2RayConfiguration( + api_port=api_port, + vmess_port=vmess_port, + vmess_address=vmess_address, + vmess_uid=f"{self.uid_16}", + vmess_transport=tp, + proxy_port=1080, + proxy_protocol=pp + ) + # ConfParams.KEYRINGDIR (.meile-gui) + config_file = path.join(ConfParams.KEYRINGDIR, "v2ray_config.json") + if path.isfile(config_file) is True: + remove(config_file) + with open(config_file, "w", encoding="utf-8") as f: + f.write(json.dumps(v2ray_config.get(), indent=4)) - vmess_address = resolve_address(response['result']['addrs'][0]) - vmess_port = int(decode['metadata'][0]['port']) - pp = proxy_protocol[decode['metadata'][0]['proxy_protocol']-1] - #tp = transport_protocol[decode['metadata'][0]['transport_protocol']-1] - tp = transport_protocol[1] - #ts = transport_security[decode['metadata'][0]['transport_security']-1] - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind(('', 0)) - api_port = sock.getsockname()[1] - sock.close() - - print("api_port", api_port) - print("vmess_port", vmess_port) - print("vmess_address", vmess_address) - print("vmess_uid", f"{uid_16}") - print("vmess_transport", tp) - print("proxy_protocol", pp) + proxy_ip_file = path.join(ConfParams.KEYRINGDIR, "v2ray.proxy") + if path.isfile(proxy_ip_file) is True: + remove(proxy_ip_file) + with open(proxy_ip_file, "w", encoding="utf-8") as f: + f.write(vmess_address) - if self.FRAGMENT: - v2ray_config = V2RayFragmentConfiguration( - api_port=api_port, - vmess_port=vmess_port, - vmess_address=vmess_address, - vmess_uid=f"{uid_16}", - vmess_transport=tp, - proxy_port=1080, - proxy_protocol=pp - ) - else: - v2ray_config = V2RayConfiguration( - api_port=api_port, - vmess_port=vmess_port, - vmess_address=vmess_address, - vmess_uid=f"{uid_16}", - vmess_transport=tp, - proxy_port=1080, - proxy_protocol=pp - ) - # ConfParams.KEYRINGDIR (.meile-gui) - config_file = path.join(ConfParams.KEYRINGDIR, "v2ray_config.json") - if path.isfile(config_file) is True: - remove(config_file) - with open(config_file, "w", encoding="utf-8") as f: - f.write(json.dumps(v2ray_config.get(), indent=4)) - - proxy_ip_file = path.join(ConfParams.KEYRINGDIR, "v2ray.proxy") - if path.isfile(proxy_ip_file) is True: - remove(proxy_ip_file) - with open(proxy_ip_file, "w", encoding="utf-8") as f: - f.write(vmess_address) - - # v2ray_tun2routes_connect_bash - # >> hardcoded = proxy port >> 1080 - # >> hardcoded = v2ray file >> /home/${USER}/.meile-gui/v2ray_config.json - - tuniface = False - v2ray_handler = V2RayHandler(f"{v2ray_tun2routes_connect_bash} up") - v2ray_handler.start_daemon() - sleep(14) - - if pltfrm != Arch.OSX: - for iface in psutil.net_if_addrs().keys(): - if "tun" in iface: - tuniface = True - break - else: - if psutil.net_if_addrs().get("utun123"): - self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": True, "status" : "utun123"} - print(self.connected) + # v2ray_tun2routes_connect_bash + # >> hardcoded = proxy port >> 1080 + # >> hardcoded = v2ray file >> /home/${USER}/.meile-gui/v2ray_config.json + + tuniface = False + v2ray_handler = V2RayHandler(f"{v2ray_tun2routes_connect_bash} up") + v2ray_handler.start_daemon() + sleep(14) + + if pltfrm != Arch.OSX: + for iface in psutil.net_if_addrs().keys(): + if "tun" in iface: tuniface = True - - if tuniface is True: - self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": True, "status" : tuniface} + break + else: + if psutil.net_if_addrs().get("utun123"): + self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": True, "status" : "utun123"} print(self.connected) - conndesc.write("Checking network connection...\n") + tuniface = True + + if tuniface is True: + self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": True, "status" : tuniface} + print(self.connected) + conndesc.write("Checking network connection...\n") + conndesc.flush() + sleep(1) + self.get_ip_address() + sleep(1) + conndesc.close() + # os x + chdir(MeileConfig.BASEDIR) + return + else: + try: + conndesc.write("Error connecting to V2Ray node...\n") conndesc.flush() - sleep(1) - self.get_ip_address() - sleep(1) + v2ray_handler.v2ray_script = f"{v2ray_tun2routes_connect_bash} down" + v2ray_handler.kill_daemon() conndesc.close() - # os x - #chdir(MeileConfig.BASEDIR) - return - else: - try: - conndesc.write("Error connecting to V2Ray node...\n") - conndesc.flush() - v2ray_handler.v2ray_script = f"{v2ray_tun2routes_connect_bash} down" - v2ray_handler.kill_daemon() - conndesc.close() - except Exception as e: - print(str(e)) + except Exception as e: + print(str(e)) - self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": False, "status": f"Error connecting to v2ray node: {tuniface}"} - print(self.connected) - # os x - #chdir(MeileConfig.BASEDIR) - return + self.connected = {"v2ray_pid" : v2ray_handler.v2ray_pid, "result": False, "status": f"Error connecting to v2ray node: {tuniface}"} + print(self.connected) + # os x + chdir(MeileConfig.BASEDIR) + return # os x - #chdir(MeileConfig.BASEDIR) + chdir(MeileConfig.BASEDIR) self.connected = {"v2ray_pid" : None, "result": False, "status": "Bad Response from Node"} return diff --git a/src/conf/meile_config.py b/src/conf/meile_config.py index 1fb3f3f..4afb3cf 100755 --- a/src/conf/meile_config.py +++ b/src/conf/meile_config.py @@ -95,7 +95,9 @@ def read_configuration(self, confpath): if not self.CONFIG.has_option('network', 'dns'): self.CONFIG.set('network', 'dns', '1.1.1.1') if not self.CONFIG.has_option('network', 'ringsessions'): - self.CONFIG.set('network', 'ringsessions', '0') + self.CONFIG.set('network', 'ringsessions', '1') + if self.CONFIG.has_option('network', 'ringsessions'): + self.CONFIG.set('network', 'ringsessions', '1') FILE = open(self.CONFFILE, 'w') self.CONFIG.write(FILE) diff --git a/src/helpers/update_checker.py b/src/helpers/update_checker.py new file mode 100644 index 0000000..1bf4bee --- /dev/null +++ b/src/helpers/update_checker.py @@ -0,0 +1,98 @@ +import requests +import re +from packaging import version +from typedef.konstants import TextStrings + + + +class UpdateChecker: + + def __init__(self): + self.latest_version = None + self.current_version = TextStrings.VERSION + self.release_notes = [] + self.update_available = False + + def check_for_update(self): + try: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "Meile-dVPN-UpdateChecker" + } + response = requests.get(TextStrings.GITHUB_API_URL, headers=headers, timeout=10) + response.raise_for_status() + data = response.json() + + self.latest_version = data.get("tag_name", "").strip() + body = data.get("body", "") + self.release_notes = self._parse_whats_new(body, max_items=3) + + current_clean = self._strip_v(self.current_version) + latest_clean = self._strip_v(self.latest_version) + + if version.parse(latest_clean) > version.parse(current_clean): + self.update_available = True + return { + "current_version": self.current_version, + "latest_version": self.latest_version, + "release_notes": self.release_notes, + "download_url": TextStrings.DOWNLOAD_URL, + } + + return None + + except requests.RequestException as e: + print(f"[UpdateChecker] Network error: {e}") + return None + except Exception as e: + print(f"[UpdateChecker] Error: {e}") + return None + + @staticmethod + def _strip_v(version_string: str) -> str: + return version_string.lstrip("vV") + + @staticmethod + def _parse_whats_new(body: str, max_items: int = 2) -> list: + lines = body.split("\n") + in_whats_new = False + items = [] + + for line in lines: + stripped = line.strip() + + if re.match(r"^(#{1,6}\s+)?\*{0,2}What'?s\s+New\*{0,2}\s*$", + stripped, re.IGNORECASE): + in_whats_new = True + continue + + if in_whats_new: + bullet_match = re.match(r"^[-*]\s+(.+)$", stripped) + if bullet_match: + items.append(bullet_match.group(1).strip()) + if len(items) >= max_items: + break + elif stripped.startswith("#") or ( + stripped.startswith("**") and stripped.endswith("**") + ): + break + + return items + +def format_update_message(update_info: dict) -> str: + latest = update_info["latest_version"] + current = update_info["current_version"] + notes = update_info["release_notes"] + url = update_info["download_url"] + + msg = f"Meile dVPN [b]{latest}[/b] is now available! (You have {current})\n\n" + + if notes: + msg += "[b]What's New:[/b]\n" + for note in notes: + msg += f" • {note}\n" + msg += " & more!\n\n" + + msg += f"Visit [color=#3CDAB7][ref=download]{url}[/ref][/color] to download the latest release." + + return msg \ No newline at end of file diff --git a/src/helpers/v2ray.py b/src/helpers/v2ray.py new file mode 100644 index 0000000..8aec5b9 --- /dev/null +++ b/src/helpers/v2ray.py @@ -0,0 +1,78 @@ +import json +import base64 +import sys + +def generate_v2ray_uri(config_path): + with open(config_path, 'r') as f: + config = json.load(f) + + # Find the vmess outbound + outbound = None + for o in config.get('outbounds', []): + if o.get('protocol') == 'vmess' or o.get('protocol') == 'vless': + outbound = o + break + + if not outbound: + print("No VMess outbound found in config") + sys.exit(1) + + vnext = outbound['settings']['vnext'][0] + user = vnext['users'][0] + stream = outbound.get('streamSettings', {}) + + network = stream.get('network', 'tcp') + security = stream.get('security', 'none') + + vmess = { + "v": "2", + "ps": "My VPN", # nickname, change as you like + "add": vnext['address'], + "port": str(vnext['port']), + "id": user['id'], + "aid": str(user.get('alterId', 0)), + "scy": user.get('security', 'auto'), + "net": network, + "type": "none", + "host": "", + "path": "", + "tls": "tls" if security == "tls" else "" + } + + # WebSocket settings + if network == 'ws': + ws = stream.get('wsSettings', {}) + vmess['path'] = ws.get('path', '') + vmess['host'] = ws.get('headers', {}).get('Host', '') + + # HTTP/2 settings + elif network == 'h2': + h2 = stream.get('httpSettings', {}) + vmess['path'] = h2.get('path', '') + hosts = h2.get('host', []) + vmess['host'] = hosts[0] if hosts else '' + + # gRPC settings + elif network == 'grpc': + grpc = stream.get('grpcSettings', {}) + vmess['path'] = grpc.get('serviceName', '') + vmess['type'] = 'gun' + + # TCP with HTTP obfs + elif network == 'tcp': + tcp = stream.get('tcpSettings', {}) + header = tcp.get('header', {}) + if header.get('type') == 'http': + vmess['type'] = 'http' + + # TLS SNI + tls_settings = stream.get('tlsSettings', {}) + if not vmess['host'] and tls_settings.get('serverName'): + vmess['host'] = tls_settings['serverName'] + + json_str = json.dumps(vmess, separators=(',', ':')) + b64 = base64.b64encode(json_str.encode('utf-8')).decode('utf-8') + uri = f"{o.get('protocol')}://{b64}" + + #print("\n✅ VMess URI:\n") + return uri diff --git a/src/imgs/location_marker.png b/src/imgs/location_marker.png new file mode 100644 index 0000000..8f655ff Binary files /dev/null and b/src/imgs/location_marker.png differ diff --git a/src/imgs/plans_basic.png b/src/imgs/plans_basic.png new file mode 100644 index 0000000..7c9c91b Binary files /dev/null and b/src/imgs/plans_basic.png differ diff --git a/src/imgs/plans_premium.png b/src/imgs/plans_premium.png new file mode 100644 index 0000000..ac64e3b Binary files /dev/null and b/src/imgs/plans_premium.png differ diff --git a/src/kv/meile.kv b/src/kv/meile.kv index 76a7eb4..95de173 100755 --- a/src/kv/meile.kv +++ b/src/kv/meile.kv @@ -44,19 +44,33 @@ WindowManager: MDBoxLayout: orientation: 'vertical' md_bg_color: get_color_from_hex("#121212") - MDFloatLayout: - size_hint_y: .1 - + + # ── Top Bar ── + MDBoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(56) + padding: [dp(8), 0, dp(8), 0] + + # Left: logo icon Image: source: root.get_logo() size_hint: None, None - height: sp(50) - pos_hint: {'x' : .0001, 'center_y' : .5} + size: dp(50), dp(50) + pos_hint: {'center_y': .5} + + # Left: logo text Image: source: root.get_logo_text() + size_hint_x: None + width: dp(150) size_hint_y: .6 - size_hint_x: .6 - pos_hint: {'x' : -.15, 'center_y' : .5} + pos_hint: {'center_y': .5} + + # Spacer pushes right-side icons to the end + Widget: + size_hint_x: 1 + ToolTipMDIconButton: id: doh opacity: 0 @@ -65,7 +79,8 @@ WindowManager: theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.INDICATOR) on_release: root.menu_open() - pos_hint: {'x' : .90, 'center_y' : .5} + pos_hint: {'center_y': .5} + ToolTipMDIconButton: id: settings_menu tooltip_text: "Menu" @@ -73,83 +88,104 @@ WindowManager: theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) on_release: root.menu_open() - pos_hint: {'x' : .95, 'center_y' : .5} - + pos_hint: {'center_y': .5} + + # ── Main Content ── MDBoxLayout: id: country_map orientation: "horizontal" + + # Left sidebar MDBoxLayout: orientation: "vertical" - size_hint: (.25, 1) - MDFloatLayout: - size_hint: None,.175 + size_hint_x: .25 + + # ── Connect button + Search area ── + MDBoxLayout: + orientation: 'vertical' + size_hint_y: None + height: dp(110) + padding: [dp(8), dp(16), dp(8), dp(4)] + spacing: dp(4) + + # Connect button — own row, centered, large TooltipMDFlatButton: - #tooltip_text: "Connect to Node" - #md_bg_color: get_color_from_hex("#121212") - pos_hint: {'x' : .4, 'y': .6} + size_hint: None, None + size: dp(70), dp(40) + pos_hint: {'center_x': .2} on_release: root.connect_routine() - opacity: 1 Image: id: connect_button - size_hint: 3,3 + size_hint: 2.2, 2.2 + pos: self.parent.pos + size: self.parent.size source: root.return_connect_button("c") - - - MDTextField: - opacity: 1 - id: search_box - hint_text: "key: , value:" - icon_right: "magnify" - pos_hint: {'x' : .1, 'y': 0} - size_hint_x: 1.9 - size_hint_y: .6 - font_size: sp(12) - on_text_validate: root.on_enter_search() - - ToolTipMDIconButton: - tooltip_text: "Clear" - pos_hint: {'x' : 2.1, 'y': .145} - on_release: root.restore_results() - icon: "restore" - theme_text_color: "Custom" - text_color: get_color_from_hex(MeileColors.MEILE) + + # Search row: text field + clear button + MDBoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(48) + spacing: dp(4) + + MDTextField: + id: search_box + hint_text: "key: , value:" + icon_right: "magnify" + size_hint_x: 1 + font_size: sp(12) + on_text_validate: root.on_enter_search() + + ToolTipMDIconButton: + tooltip_text: "Clear" + on_release: root.restore_results() + icon: "restore" + size_hint_x: None + width: dp(48) + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.MEILE) + + # ── Node list ── NodeRV: - md_bg_color: get_color_from_hex("#121212") + md_bg_color: get_color_from_hex("#121212") id: rv - size_hint: (1,1) - MDBoxLayout: - size_hint: (1,None) - height: 50 - spacing: 6 - + size_hint_y: 1 # fills remaining vertical space + + # ── Bottom toolbar ── + MDBoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: dp(48) + spacing: dp(6) + ToolTipMDIconButton: tooltip_text: "Wallet" icon: "wallet-outline" theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) on_release: root.build_wallet_interface() - - #ToolTipMDIconButton: - # tooltip_text: "Subscriptions" - # icon: "book-open-outline" - # theme_text_color: "Custom" - # text_color: get_color_from_hex(MeileColors.MEILE) - # on_release: root.switch_to_sub_window() - + ToolTipMDIconButton: tooltip_text: "Plans" icon: "ballot-outline" theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) on_release: root.switch_to_plan_window() - + + ToolTipMDIconButton: + tooltip_text: "Connection Sharing" + icon: "qrcode" + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.MEILE) + on_release: root.qrcode_connection_sharing() + ToolTipMDIconButton: tooltip_text: "Settings" icon: "cog-outline" theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) on_release: root.build_settings_screen_interface() - + ToolTipMDIconButton: tooltip_text: "Help" icon: "help-circle-outline" @@ -171,10 +207,10 @@ WindowManager: id: protected - font_size: sp(26) - pos_hint: {"x" : .4, "center_y": .975} + #font_size: sp(60) + pos_hint: {"x" : .375, "center_y": .975} opacity: .75 - font_style: "H6" + font_style: "H4" text: "UNPROTECTED" theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) @@ -196,8 +232,10 @@ WindowManager: text_color_focus: '#fcb711' text_color_normal: '#fcb711' #hint_text: 'IP Address:' - #fill_color_normal: get_color_from_hex("#121212") - #fill_color_focus: get_color_from_hex("#000000") + fill_color_normal: (0,0,0,0) + fill_color_focus: (0,0,0,0) + line_color_normal: '#fcb711' + line_color_focus: '#fcb711' #fill_color_normal: 0,0,0,0 #fill_color_focus: 0,0,0,0 @@ -212,11 +250,38 @@ WindowManager: opacity: .5 icon_left: "laptop" text_color_focus: '#fcb711' - text_color_normal: '#fcb711' + text_color_normal: '#fcb711' + fill_color_normal: 0, 0, 0, 0 + fill_color_focus: 0, 0, 0, 0 + line_color_normal: '#fcb711' + line_color_focus: '#fcb711' #hint_text_color_normal: '#fcb711' #hint_text_color_focus: '#fcb711' #fill_color_normal: get_color_from_hex("#121212") #fill_color_focus: get_color_from_hex("#000000") + + + id: protocol_label + mode: "round" + size_hint_x: .15 + size_hint_y: .05 + pos_hint: {"x" : 0.80, "center_y": .1} + readonly: True + opacity: .5 + icon_left: "security-network" + text: "" + hint_text: 'Protocol' + font_size: sp(12) + foreground_color: 0.988, 0.718, 0.067, 1 + background_color: 0, 0, 0, 0 + background_normal: '' + background_active: '' + text_color_focus: '#fcb711' + text_color_normal: '#fcb711' + fill_color_normal: 0, 0, 0, 0 + fill_color_focus: 0, 0, 0, 0 + line_color_normal: '#fcb711' + line_color_focus: '#fcb711' text: "Bandwidth" @@ -271,6 +336,28 @@ WindowManager: theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.UPLOAD) font_name: root.get_font() + + + font_size: "100sp" + pos_hint: {"x" : .84, "center_y": .04} + opacity: .75 + #font_style: "H6" + text: "00:00:00" + markup: True + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.MEILE) + font_name: root.get_font() + + + font_size: "100sp" + pos_hint: {"x" : .84, "center_y": .04} + opacity: .75 + #font_style: "H6" + text: "00:00:00" + markup: True + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.MEILE) + font_name: root.get_font() id: quota_pct @@ -954,32 +1041,33 @@ WindowManager: size_hint_x: .99 pos_hint: {"center_x": 0.5} MDGridLayout: - rows: 3 - size_hint_x: 1 + rows: 5 + size_hint_x: .9 MDGridLayout: - cols: 7 + cols: 6 height: 25 adaptive_height: True padding: [15, 25, 0, 25] MDLabel: - text: "Plan" + text: " Plan" bold: True size_hint_x: 2.9 + halign: "center" MDLabel: - text: "Nodes" + text: " Nodes" bold: True size_hint_x: 1.1 MDLabel: - text: "Countries" + text: " Countries" bold: True size_hint_x: 1.5 MDLabel: - text: "Cost" + text: " Cost" bold: True size_hint_x: 1.4 @@ -997,41 +1085,81 @@ WindowManager: on_release: root.set_previous_screen() MDGridLayout: - cols: 1 + cols: 1 + rows: 4 height: sp(25) adaptive_height: True padding: [15, 25, 0, 25] - MDLabel: - height: sp(35) - font_size: sp(13) - theme_text_color: "Custom" - text_color: get_color_from_hex(MeileColors.WHITE) - text: "In order to use plans that you subscribe to, click on the subscribed plan to expand it. This will select the plan. Click 'filter nodes' to find the online nodes on the plan. They will be available in the countires on the left bar. Navigate to your desired node, click on it, and then press connect." - - #MDGridLayout: - # cols: 1 - # height: sp(25) - # adaptive_height: True - # padding: [15, 25, 0, 25] - # MDLabel: - # height: sp(35) - # font_size: sp(14) - # theme_text_color: "Custom" - # text_color: get_color_from_hex(MeileColors.WHITE) - # text: "" + MDLabel: + height: sp(35) + font_size: sp(15) + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.WHITE) + text: "In order to use plans that you subscribe to, click on the subscribed plan to expand it. This will select the plan and automatically filter the nodes on the left bar which are on the plan (i.e., connectible). Navigate to your desired node, click on it, and then press connect." + MDLabel: + MDLabel: + + MDGridLayout: + cols: 1 + height: sp(25) + adaptive_height: True + padding: [15, 25, 0, 25] + MDLabel: + height: sp(35) + font_size: sp(14) + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.WHITE) + text: "" + MDGridLayout: + cols: 2 + height: sp(25) + adaptive_height: True + padding: [15, 25, 0, 25] + AsyncImage: + source: root.get_plan_image("b") + size_hint: 1, None + size: dp(215), dp(396) + pos_hint: {"center_x": 0.5} + allow_stretch: True + keep_ratio: True + padding: [15, 10, 15, 10] + AsyncImage: + source: root.get_plan_image("p") + size_hint: 1, None + size: dp(225), dp(396) + pos_hint: {"center_x": 0.5} + allow_stretch: True + keep_ratio: True + padding: [15, 10, 15, 10] - RecycleView: + #RecycleView: + # do_scroll_y: True + # size_hint_x: 1 + # size_hint_y: None + # height: sp(200) + # MDBoxLayout: + # size_hint_y: None + # size_hint_x: 1 + # adaptive_height: True + # orientation: "vertical" + # padding: [10, 10, 10, 50] + # spacing: 10 + # id: rv + ScrollView: do_scroll_y: True + do_scroll_x: False size_hint_x: 1 + size_hint_y: None + height: sp(400) + bar_width: dp(10) MDBoxLayout: + id: rv size_hint_y: None size_hint_x: 1 - adaptive_height: True - + height: self.minimum_height orientation: "vertical" padding: [10, 10, 10, 50] - spacing: 10 - id: rv + spacing: 10 : size_hint_x: .95 @@ -1084,6 +1212,8 @@ WindowManager: : rows: 2 cols: 6 + size_hint_y: None + height: self.minimum_height AsyncImage: size_hint_x: .1 width: 100 @@ -1179,9 +1309,11 @@ WindowManager: : rows: 3 - cols: 6 - height: 90 - + cols: 5 + height: 90 # different on oses + size_hint_y: None + #height: self.minimum_height + MDLabel text: "[b]UUID[/b]" markup: True @@ -1204,13 +1336,13 @@ WindowManager: text: "[b]Coin[/b]" markup: True - TooltipMDIconButton: - icon: "table-filter" - tooltip_text: "Filter Nodes" - size_hint_x: .25 - theme_text_color: "Custom" - text_color: app.theme_cls.primary_color - on_release: root.filter_nodes() + #TooltipMDIconButton: + # icon: "table-filter" + # tooltip_text: "Filter Nodes" + # size_hint_x: .25 + # theme_text_color: "Custom" + # text_color: app.theme_cls.primary_color + # on_release: root.filter_nodes() MDLabel text: root.uuid @@ -1220,27 +1352,27 @@ WindowManager: MDLabel text: root.id markup: True - font_size: sp(10) + font_size: sp(13) MDLabel text: root.expires markup: True - font_size: sp(11) + font_size: sp(13) size_hint_x: 1.3 MDLabel text: root.deposit markup: True - font_size: sp(11) + font_size: sp(13) MDLabel text: root.coin markup: True - font_size: sp(11) + font_size: sp(13) MDLabel MDLabel MDLabel MDLabel MDLabel - MDLabel + : rows: 2 @@ -1307,12 +1439,10 @@ WindowManager: : rows: 2 - #md_bg_color: get_color_from_hex("#1C1D1B") md_bg_color: get_color_from_hex(MeileColors.DIALOG_BG_COLOR2) radius: [5, 5, 5, 5] - - height: 50 - adaptive_height: True + size_hint_y: None + height: self.minimum_height : @@ -2854,3 +2984,75 @@ WindowManager: theme_text_color: "Custom" text_color: get_color_from_hex(MeileColors.MEILE) font_size: "16sp" + +: + orientation: 'vertical' + spacing: "20dp" + padding: "20dp" + size_hint_y: None + height: "300sp" + + Image: + id: qr_img + size_hint: None, None + size: "192dp", "192dp" + pos_hint: {'center_x': 0.5} + + MDTextField: + id: uri + hint_text: "URI" + text: '' + readonly: True + selectable: True + mode: "rectangle" + MDLabel: + id: warning_comment + text: "" + theme_text_color: "Custom" + text_color: get_color_from_hex(MeileColors.MEILE) + font_size: "13sp" + + + orientation: "vertical" + spacing: "1sp" + size_hint_y: None + height: "130sp" + price_text: "" + naddress: " " + moniker: " " + deposit: " " + MDFloatLayout: + MDLabel: + id: sub_type + text: "Select Protocol" + theme_text_color: "Custom" + font_style: "Subtitle2" + font_size: "20sp" + text_color: get_color_from_hex(MeileColors.MEILE) + pos_hint: {"x" : 0, "top" : 1.35} + MDLabel: + id: bandwidth_text + text: "Wireguard" + theme_text_color: "Custom" + font_style: "Subtitle2" + font_size: "14sp" + text_color: get_color_from_hex("#ffffff") + pos_hint: {"x" : 0, "top" : 1} + Check: + group: 'proto' + on_active: root.select_share_type(self, self.active, "wg") + pos_hint: {"x": .5, "y" : .30} + MDLabel: + pos_hint: {"x" : 0, "top" : .9} + MDLabel: + id: hourly_text + text: "V2Ray" + theme_text_color: "Custom" + font_style: "Subtitle2" + font_size: "14sp" + text_color: get_color_from_hex("#ffffff") + pos_hint: {"x" : 0, "top" : .7} + Check: + group: 'proto' + on_active: root.select_share_type(self, self.active, "v2") + pos_hint: {"x": .5, "y" : .025} diff --git a/src/typedef/konstants.py b/src/typedef/konstants.py index 205b845..c94f46e 100755 --- a/src/typedef/konstants.py +++ b/src/typedef/konstants.py @@ -29,7 +29,8 @@ class ConfParams(): DEFAULT_SUBS = [5 * i for i in range(1, 6)] BTCPAYADJ = 1.10 XMRPAYADJ = 3.14 - SUBFEE = 0.042069 + SUBFEE = 0.1 + SUBFEEMULT = 10 class HTTParams(): # Note http://128.199.90.172:26657 is testnet ONLY! @@ -602,9 +603,11 @@ class IBCTokens(): #mu_coins = ["tsent", "udvpn", "uscrt", "uosmo", "uatom", "udec"] class TextStrings(): dash = "-" - VERSION = "2.2.2" - BUILD = "1722988800718" + VERSION = "v2.5.4" + BUILD = "17748518213" RootTag = "SENTINEL" + GITHUB_API_URL = "https://api.github.com/repos/MathNodes/meile-gui/releases/latest" + DOWNLOAD_URL = "https://meile.app" PassedHealthCheck = "Passed Sentinel Health Check" FailedHealthCheck = "Failed Sentinel Health Check" @@ -624,12 +627,19 @@ class MeileColors(): FONT_FACE_ARIAL = "../fonts/arial-unicode-ms.ttf" QR_FONT_FACE = "../fonts/Roboto-BoldItalic.ttf" MAP_MARKER = "../imgs/location_pin.png" + LOC_MARKER = "../imgs/location_marker.png" LOGO = "../imgs/logo.png" LOGO_HD = "../imgs/logo_hd.png" LOGO_TEXT = "../imgs/logo_text.png" SUBSCRIBE_BUTTON = "../imgs/SubscribeButton.png" GETINFO_BUTTON = "../imgs/GetInfoButton.png" SPINNER = "../imgs/spinner.png" + CONNECT_BUTTON = "../imgs/ConnectButton.png" + DISCONNECT_BUTTON = "../imgs/DisconnectButton.png" + WIREGUARD_ICON = "../utils/coinimg/wireguard.png" + V2RAY_ICON = "../utils/coinimg/v2ray.png" + BASIC_PLAN = "../imgs/plans_basic.png" + PREMIUM_PLAN = "../imgs/plans_premium.png" HEALTH_ICON = "shield-plus" SICK_ICON = "emoticon-sick" ARCGIS_MAP = "https://server.arcgisonline.com/arcgis/rest/services/Canvas/World_Dark_Gray_Base/MapServer/tile/{z}/{y}/{x}.png" diff --git a/src/ui/interfaces.py b/src/ui/interfaces.py index 68b8ea7..9787a07 100755 --- a/src/ui/interfaces.py +++ b/src/ui/interfaces.py @@ -26,7 +26,7 @@ class ProtectedLabel(MDLabel): def get_font(self): Config = MeileGuiConfig() - return Config.resource_path(MeileColors.QR_FONT_FACE) + return Config.resource_path(MeileColors.FONT_FACE) class DownloadLabel(MDLabel): def get_font(self): @@ -37,6 +37,10 @@ class UploadLabel(MDLabel): def get_font(self): Config = MeileGuiConfig() return Config.resource_path(MeileColors.FONT_FACE) +class TimeLabel(MDLabel): + def get_font(self): + Config = MeileGuiConfig() + return Config.resource_path(MeileColors.FONT_FACE) class MapCenterButton(MDIconButton, MDTooltip): pass @@ -47,6 +51,9 @@ class ToolTipMDIconButton(MDIconButton, MDTooltip): class IPAddressTextField(MDTextField): pass +class ProtocolTextField(MDTextField): + pass + class ConnectedNode(MDTextField): pass @@ -122,4 +129,7 @@ class QRDialogContent(MDBoxLayout): pass class QRDialogZanoContent(MDBoxLayout): - pass \ No newline at end of file + pass + +class QRDialogV2RayContent(MDBoxLayout): + pass diff --git a/src/ui/screens.py b/src/ui/screens.py index 2b5a174..a5c4c39 100644 --- a/src/ui/screens.py +++ b/src/ui/screens.py @@ -1,11 +1,11 @@ from geography.continents import OurWorld -from ui.interfaces import LatencyContent, TooltipMDIconButton, ConnectionDialog, ProtectedLabel, IPAddressTextField, ConnectedNode, QuotaPct,BandwidthBar,BandwidthLabel, MapCenterButton, UploadLabel, DownloadLabel +from ui.interfaces import LatencyContent, TooltipMDIconButton, ConnectionDialog, ProtectedLabel, IPAddressTextField, ProtocolTextField, ConnectedNode, QuotaPct,BandwidthBar,BandwidthLabel, MapCenterButton, UploadLabel, DownloadLabel,QRDialogV2RayContent, TimeLabel from typedef.win import WindowNames from cli.sentinel import NodeTreeData from typedef.konstants import NodeKeys, TextStrings, MeileColors, HTTParams, IBCTokens, ConfParams from cli.sentinel import disconnect as Disconnect import main.main as Meile -from ui.widgets import WalletInfoContent, SeedInfoContent, MDMapCountryButton, RatingContent, NodeRV, NodeRV2, NodeAccordion, NodeRow, NodeDetails, PlanAccordion, PlanRow, PlanDetails, NodeCarousel, SubTypeDialog, SubscribeContent, LoadingSpinner +from ui.widgets import WalletInfoContent, SeedInfoContent, MDMapCountryButton, RatingContent, NodeRV, NodeRV2, NodeAccordion, NodeRow, NodeDetails, PlanAccordion, PlanRow, PlanDetails, NodeCarousel, SubTypeDialog, SubscribeContent, LoadingSpinner, ShareTypeDialog from utils.qr import QRCode from cli.wallet import HandleWalletFunctions from conf.meile_config import MeileGuiConfig @@ -19,6 +19,9 @@ from helpers.helpers import format_byte_size from helpers.bandwidth import compute_consumed_data, compute_consumed_hours, init_GetConsumedWhileConnected, GetConsumedWhileConnected, GetTotalDataWhileConnected from helpers.aes import SecureSeed +from helpers.v2ray import generate_v2ray_uri +from helpers.update_checker import UpdateChecker, format_update_message +from ui.update_dialog import UpdateDialog from kivy.properties import BooleanProperty, StringProperty, ColorProperty,ObjectProperty, NumericProperty from kivy.uix.screenmanager import Screen, SlideTransition @@ -41,7 +44,7 @@ from kivy.uix.floatlayout import FloatLayout from kivymd.uix.anchorlayout import MDAnchorLayout from kivymd.uix.label.label import MDLabel - +from kivy.animation import Animation from kivy.app import App import requests @@ -50,7 +53,7 @@ import copy from copy import deepcopy import re -from time import sleep +from time import sleep, time from functools import partial from shutil import rmtree from os import path,geteuid, chdir, remove @@ -361,7 +364,10 @@ class MainWindow(Screen): dnscrypt = False PlanConnect = False HourlyFirstRun = True - + location_marker = None + is_running = False + start_time = 0 + hwf = None def __init__(self, node_tree, **kwargs): @@ -393,7 +399,7 @@ def __init__(self, node_tree, **kwargs): width_mult=3, position="center", max_height=max_height, - background_color=get_color_from_hex(MeileColors.BLACK)) + md_bg_color=get_color_from_hex(MeileColors.BLACK)) def update_wallet(self, dt): MeileConfig = MeileGuiConfig() @@ -445,11 +451,11 @@ def connect(): pass - hwf = HandleWalletFunctions() + self.hwf = HandleWalletFunctions() thread = Thread(target=lambda: self.ping()) thread.start() if not self.SubCaller: - t = Thread(target=lambda: hwf.connect(ID, + t = Thread(target=lambda: self.hwf.connect(ID, naddress, proto, deposit, @@ -457,7 +463,7 @@ def connect(): plan=PlanConnect)) t.start() else: - t = Thread(target=lambda: hwf.connect(0, + t = Thread(target=lambda: self.hwf.connect(0, node, protocol, sub_deposit, @@ -485,10 +491,10 @@ def connect(): #conndesc.close() self.cd.ids.pb.value = 1 - self.ConnectedDict = deepcopy(hwf.connected) + self.ConnectedDict = deepcopy(self.hwf.connected) yield 0.420 try: - if hwf.connected['result']: + if self.hwf.connected['result']: print("CONNECTED!!!") self.CONNECTED = True Moniker = self.NodeCarouselData['moniker'] @@ -503,27 +509,67 @@ def connect(): #print("REmove loading Widget") # Here change the Connection button to a "Disconnect" button then display dialogAdd commentMore actions self.set_protected_icon(True, Moniker) + self.toggle_time_widget() + #if "V2Ray" in [proto, protocol]: + connected_content = QRDialogV2RayContent() + QRcode = QRCode() + if "V2Ray" in [proto, protocol]: + uri = generate_v2ray_uri(path.join(ConfParams.KEYRINGDIR,"v2ray_config.json")) + connected_content.ids.uri.text = uri + connected_content.ids.warning_comment.text = "Scan the QR code or import the URI string into the V2RayNG mobile app. You must do this before you disconnect in Meile. https://dvpn.my/v2ray" + connected_content.ids.qr_img.source = QRcode.generate_qr_code(uri, "v2ray") + else: + WG_PATH = path.join(ConfParams.KEYRINGDIR,"wg99.conf") + with open(WG_PATH, "r") as f: + wg_config = f.read() + wg_config = wg_config.replace("127.0.0.1,", "") + connected_content.ids.uri.text = wg_config + connected_content.ids.warning_comment.text = "Scan the QR code or input the config with the official Wireguard app. You must do this before you disconnect in Meile. https://dvpn.my/wireguard" + connected_content.ids.qr_img.source = QRcode.generate_wg_qr_code(WG_PATH, self.NodeCarouselData['moniker']) + self.dialog = MDDialog( title="Connected!", + type="custom", + content_cls=connected_content, md_bg_color=get_color_from_hex(MeileColors.BLACK), buttons=[ - MDFlatButton( + MDRaisedButton( text="OK", theme_text_color="Custom", - text_color=get_color_from_hex(MeileColors.MEILE), + text_color=get_color_from_hex(MeileColors.BLACK), on_release=partial(self.call_ip_get, True, Moniker ) - ),]) + ), + ] + ) self.dialog.open() + ''' + else: + self.dialog = MDDialog( + title="Connected!", + md_bg_color=get_color_from_hex(MeileColors.BLACK), + buttons=[ + MDFlatButton( + text="OK", + theme_text_color="Custom", + text_color=get_color_from_hex(MeileColors.MEILE), + on_release=partial(self.call_ip_get, + True, + Moniker + ) + ),]) + self.dialog.open() + + ''' else: self.remove_loading_widget2() self.dialog = MDDialog( title="Something went wrong. Not connected: ", - text=hwf.connected['status'] if hwf.connected['status'] else "Connection Error", + text=self.hwf.connected['status'] if self.hwf.connected['status'] else "Connection Error", md_bg_color=get_color_from_hex(MeileColors.BLACK), buttons=[ MDFlatButton( @@ -554,6 +600,7 @@ def connect(): if self.PlanID: ID = self.PlanID naddress = self.NodeCarouselData['address'] + self.node_address = deepcopy(naddress) proto = self.NodeCarouselData['protocol'] deposit = "dvpn" PlanConnect = True @@ -561,6 +608,7 @@ def connect(): return elif self.SubCaller: print("Calling for a session subscription") + proto = None connect() else: @@ -581,6 +629,7 @@ def connect(): else: self.disconnect_from_node() self.HourlyFirstRun = True + self.reset_stopwatch() try: self.clock.cancel() except: @@ -592,6 +641,56 @@ def connect(): except: print("No Clock Bytes... Yet") self.clockBytes = None + + def toggle_time_widget(self): + if not self.is_running: + self.start_stopwatch() + else: + self.stop_stopwatch() + + def start_stopwatch(self): + self.is_running = True + if self.start_time == 0: + self.start_time = time() + Clock.schedule_interval(self.update_time, 1.0) + + + def stop_stopwatch(self): + self.is_running = False + Clock.unschedule(self.update_time) + + def update_time(self, dt): + if self.is_running: + self.elapsed_time = time() - self.start_time + self.time_widget.text = self.format_time(self.elapsed_time) + return True + + def format_time(self, seconds): + td = timedelta(seconds=int(seconds)) + hours = td.seconds // 3600 + minutes = (td.seconds % 3600) // 60 + secs = td.seconds % 60 + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + + def reset_stopwatch(self): + self.is_running = False + self.start_time = 0 + self.elapsed_time = 0 + self.time_widget.text = '00:00:00' + Clock.unschedule(self.update_time) + + def qrcode_connection_sharing(self): + + share_content = ShareTypeDialog() + + self.dialog = MDDialog( + title="Protocol to Connection Share", + type="custom", + content_cls=share_content, + md_bg_color=get_color_from_hex(MeileColors.BLACK), + ) + self.dialog.open() + def setTotalBytesClock(self): self.clockBytes = Clock.create_trigger(self.GetUpDownBytes,10) @@ -751,6 +850,11 @@ def build(self, dt): thread = Thread(target=lambda: self.nonblock_get_ip_address(self.get_ip_address, True)) thread.start() + if not getattr(self, '_update_checked', False): + self._update_checked = True + Thread(target=lambda: self._check_update_background(),daemon=True).start() + + def create_new_wallet(self): hwf = HandleWalletFunctions() @@ -828,6 +932,8 @@ def build_meile_map(self): self.map_widget_1 = IPAddressTextField() self.map_widget_2 = ConnectedNode() self.map_widget_3 = ProtectedLabel() + self.map_widget_4 = ProtocolTextField() + self.time_widget = TimeLabel() self.upload_widget = UploadLabel() self.download_widget = DownloadLabel() recenter = MapCenterButton() @@ -847,12 +953,15 @@ def build_meile_map(self): layout.add_widget(self.map_widget_1) layout.add_widget(self.map_widget_2) layout.add_widget(self.map_widget_3) + layout.add_widget(self.map_widget_4) layout.add_widget(bw_label) layout.add_widget(self.quota) layout.add_widget(self.quota_pct) layout.add_widget(recenter) layout.add_widget(self.upload_widget) layout.add_widget(self.download_widget) + layout.add_widget(self.time_widget) + self.quota.value = 0 self.quota_pct.text = "0%" @@ -863,6 +972,20 @@ def build_meile_map(self): self.AddCountryNodePins(False) self.MeileMapBuilt = True + def _check_update_background(self): + checker = UpdateChecker() + update_info = checker.check_for_update() + + if update_info is not None: + Clock.schedule_once(lambda dt: self._show_update_dialog(update_info), 1.0) + + def _show_update_dialog(self, update_info): + message = format_update_message(update_info) + UpdateDialog( + message=message, + download_url=update_info["download_url"] + ).show() + def check_boundaries(self, instance, value): if self.MeileMap.zoom == 1: self.recenter_map() @@ -1131,6 +1254,7 @@ def get_ip_address(self, dt, startup: bool = False): self.LatLong.append(loc[1]) if not startup: self.zoom_country_map() + self.add_location_marker() return True except Exception as e: print(str(e)) @@ -1474,16 +1598,59 @@ def call_ip_get(self,result, moniker, *kwargs): self.remove_loading_widget(None) self.close_sub_window() + def add_location_marker(self): + self.remove_location_marker() + Config = MeileGuiConfig() + + with open(path.join(ConfParams.KEYRINGDIR, 'ip-api.json'), 'r') as f: + data = f.read() + + ifJSON = json.loads(data) + if not ifJSON: + return False + + self.location_marker = MapMarkerPopup(lat=self.LatLong[0], lon=self.LatLong[1], source=Config.resource_path(MeileColors.LOC_MARKER)) + self.location_marker.add_widget(MDMapCountryButton(text='%s, %s' %(ifJSON['city'], ifJSON['country']), + theme_text_color="Custom", + md_bg_color=get_color_from_hex(MeileColors.BLACK), + text_color=(1,1,1,1), + )) + + anim = ( + Animation(opacity=0.4, duration=0.8, t='in_out_sine') + + Animation(opacity=1.0, duration=0.8, t='in_out_sine') + ) + anim.repeat = True + anim.start(self.location_marker) + + + self.Markers.append(self.location_marker) + self.MeileMap.add_marker(self.location_marker) + + def remove_location_marker(self): + if self.location_marker: + self.MeileMap.remove_marker(self.location_marker) + self.Markers.remove(self.Markers[-1]) + def set_protected_icon(self, setbool, moniker): try: if setbool: self.map_widget_2.text = moniker self.map_widget_3.text = "PROTECTED" + anim = ( + Animation(opacity=0.2, duration=0.8, t='in_out_sine') + + Animation(opacity=1.0, duration=0.8, t='in_out_sine') + ) + anim.repeat = True + anim.start(self.map_widget_3) + self.map_widget_4.text = self.NodeCarouselData['protocol'] self.ids.connect_button.source = self.return_connect_button("d") else: self.map_widget_2.text = moniker self.map_widget_3.text = "UNPROTECTED" + Animation.cancel_all(self.map_widget_3) + self.map_widget_4.text = "" self.ids.connect_button.source = self.return_connect_button("c") except Exception as e: print(str(e)) @@ -1510,11 +1677,17 @@ def remove_loading_widget2(self): def return_connect_button(self, text): MeileConfig = MeileGuiConfig() if text == "c": - button_path = "../imgs/ConnectButton.png" - return MeileConfig.resource_path(button_path) + return MeileConfig.resource_path(MeileColors.CONNECT_BUTTON) else: - button_path = "../imgs/DisconnectButton.png" - return MeileConfig.resource_path(button_path) + return MeileConfig.resource_path(MeileColors.DISCONNECT_BUTTON) + + def closeDialog(self, inst): + try: + self.dialog.dismiss() + self.dialog = None + except: + print("Dialog is NONE") + return class WalletScreen(Screen): text = StringProperty() @@ -2183,6 +2356,14 @@ def set_previous_screen(self): self.mw.carousel.remove_widget(self.mw.NodeWidget) self.mw.carousel.load_previous() + def get_plan_image(self, plan_type): + MeileConfig = MeileGuiConfig() + if plan_type == "b": + return MeileConfig.resource_path(MeileColors.BASIC_PLAN) + else: + return MeileConfig.resource_path(MeileColors.PREMIUM_PLAN) + + def finished(self, *args): self.ids.rv.remove_widget(self.label) self.ids.rv.remove_widget(self.spinner) diff --git a/src/ui/update_dialog.py b/src/ui/update_dialog.py new file mode 100644 index 0000000..46863c6 --- /dev/null +++ b/src/ui/update_dialog.py @@ -0,0 +1,82 @@ +import webbrowser + +from typedef.konstants import TextStrings, MeileColors + +from kivy.metrics import dp, sp +from kivy.properties import StringProperty +from kivy.lang import Builder +from kivy.utils import get_color_from_hex + +from kivymd.uix.dialog import MDDialog +from kivymd.uix.button import MDFlatButton, MDRaisedButton +from kivymd.uix.boxlayout import MDBoxLayout + +Builder.load_string(""" +: + orientation: "vertical" + spacing: dp(12) + padding: [dp(16), dp(8), dp(16), dp(8)] + size_hint_y: None + height: self.minimum_height + adaptive_height: True + + MDLabel: + id: update_message_label + text: root.message_text + markup: True + theme_text_color: "Custom" + text_color: 1, 1, 1, 0.87 + font_size: sp(14) + size_hint_y: None + height: self.texture_size[1] + on_ref_press: root.open_link(args[1]) +""") + +class UpdateDialogContent(MDBoxLayout): + message_text = StringProperty("") + + def open_link(self, ref_name): + if ref_name == "download": + webbrowser.open(TextStrings.DOWNLOAD_URL) + +class UpdateDialog: + + def __init__(self, message: str, download_url: str = TextStrings.DOWNLOAD_URL): + self.message = message + self.download_url = download_url + self.dialog = None + + def show(self): + if self.dialog is None: + content = UpdateDialogContent(message_text=self.message) + + self.dialog = MDDialog( + title="[color=#3CDAB7]Update Available[/color]", + type="custom", + content_cls=content, + md_bg_color=get_color_from_hex(MeileColors.BLACK), + buttons=[ + MDFlatButton( + text="LATER", + theme_text_color="Custom", + text_color=get_color_from_hex(MeileColors.MEILE), + on_release=lambda *_: self.dismiss(), + ), + MDRaisedButton( + text="DOWNLOAD", + md_bg_color=get_color_from_hex(MeileColors.MEILE), + text_color=get_color_from_hex(MeileColors.BLACK), + on_release=lambda *_: self.open_download(), + ), + ], + ) + + self.dialog.open() + + def dismiss(self): + if self.dialog: + self.dialog.dismiss() + + def open_download(self): + webbrowser.open(self.download_url) + self.dismiss() \ No newline at end of file diff --git a/src/ui/widgets.py b/src/ui/widgets.py index 3e5d7ee..64a7674 100755 --- a/src/ui/widgets.py +++ b/src/ui/widgets.py @@ -49,11 +49,12 @@ from cli.btcpay import BTCPayDB import main.main as Meile from adapters import HTTPRequests -from ui.interfaces import TXContent, ConnectionDialog, QRDialogContent, QRDialogZanoContent +from ui.interfaces import TXContent, ConnectionDialog, QRDialogContent, QRDialogZanoContent, QRDialogV2RayContent from coin_api.get_price import GetPriceAPI from adapters.ChangeDNS import ChangeDNS from kivy.uix.recyclegridlayout import RecycleGridLayout from helpers.helpers import format_byte_size +from helpers.v2ray import generate_v2ray_uri from fiat.stripe_pay.dist import scrtsxx from utils.qr import QRCode @@ -164,6 +165,67 @@ def on_release(self): def update_size(self, *args): self.size = self.texture_size # Ensure label size matches text size + +class ShareTypeDialog(BoxLayout): + + def select_share_type(self, instance, value, proto): + if not value: + return + mw = Meile.app.root.get_screen(WindowNames.MAIN_WINDOW) + mw.closeDialog(None) + #self.closeDialog() + iface = "wg99" + WG_PATH = path.join(ConfParams.KEYRINGDIR,f"{iface}.conf") + V2_PATH = path.join(ConfParams.KEYRINGDIR, "v2ray_config.json") + connected_content = QRDialogV2RayContent() + QRcode = QRCode() + if proto == "wg": + if path.isfile(WG_PATH): + with open(WG_PATH, "r") as f: + wg_config = f.read() + wg_config = wg_config.replace("127.0.0.1,", "") + connected_content.ids.uri.text = wg_config + connected_content.ids.qr_img.source = QRcode.generate_wg_qr_code(WG_PATH, "WireGuard") + + else: + connected_content.ids.uri.text = "Start a WireGuard connection in Meile first." + connected_content.ids.qr_img.source = QRcode.generate_qr_code("NULL", "wireguard") + connected_content.ids.warning_comment.text = 'Scan the QR code or import the wireguard config strings into the official WireGuard mobile app. You MUST first disconnect from the Wireguard node in Meile before connecting on mobile. https://dvpn.my/wireguard' + + else: + if path.isfile(V2_PATH): + uri = generate_v2ray_uri(path.join(ConfParams.KEYRINGDIR, "v2ray_config.json")) + else: + uri = "Start a V2Ray connection in Meile first." + + connected_content.ids.uri.text = uri + connected_content.ids.qr_img.source = QRcode.generate_qr_code(uri, "v2ray") + connected_content.ids.warning_comment.text = 'Scan the QR code or import the URI string into the V2RayNG mobile app. You must do this before you disconnect in Meile. https://dvpn.my/v2ray' + + + self.dialog = MDDialog( + title="Connection Sharing", + type="custom", + content_cls=connected_content, + md_bg_color=get_color_from_hex(MeileColors.BLACK), + buttons=[ + MDRaisedButton( + text="OK", + theme_text_color="Custom", + text_color=get_color_from_hex(MeileColors.BLACK), + on_release=self.closeDialog + ), + ] + ) + self.dialog.open() + + def closeDialog(self, inst): + try: + self.dialog.dismiss() + self.dialog = None + except: + print("Dialog is NONE") + return class SubTypeDialog(BoxLayout): @@ -1921,6 +1983,7 @@ class PlanDetails(MDGridLayout): deposit = StringProperty() coin = StringProperty() + ''' def filter_nodes(self): mw = Meile.app.root.get_screen(WindowNames.MAIN_WINDOW) @@ -1933,7 +1996,7 @@ def filter_nodes(self): mw.NodeTree.search(key=NodeKeys.NodesInfoKeys[1], value=plan_nodes_data, perfect_match=True, is_list=True) mw.refresh_country_recycler() - + ''' class PlanAccordion(ButtonBehavior, MDGridLayout): node = ObjectProperty() # Main node info @@ -1994,22 +2057,9 @@ def __init__(self, **kwargs): def finish_init(self, dt): self.add_widget(self.node) - + ''' def on_release(self): - '''TODO: - in first logic statement populate a MainScreen dictionary - with current node address and ID. - THis will be used when the user clicks on the subscription - which expands it's contents, the MainScreen dictionary - will be used to connect to subscription when the user - clicks "Connect" - Second logic statement (else) should reset the MainScreen - dictionary to prior state. - - Use: - content.node_address - content.sub_id - ''' + if len(self.children) == 1: self.add_widget(self.content) self.open_panel() @@ -2018,15 +2068,35 @@ def on_release(self): self.remove_widget(self.children[0]) self.close_panel() self.dispatch("on_close") - + ''' + def on_release(self): + if len(self.children) == 1: + self.open_panel() + self.dispatch("on_open") + else: + self.close_panel() + self.dispatch("on_close") + + @delayable def on_open(self, *args): """Called when a panel is opened.""" self.mw.PlanID = self.content.id + + t = Thread(target=lambda: self.filter_nodes()) + t.start() + + while t.is_alive(): + print(".", end="") + yield 0.5 + + self.mw.refresh_country_recycler() + def on_close(self, *args): """Called when a panel is closed.""" self.mw.PlanID = None - + self.mw.restore_results() + ''' def close_panel(self) -> None: """Method closes the panel.""" @@ -2043,7 +2113,8 @@ def close_panel(self) -> None: ) anim.bind(on_complete=self._disable_anim) anim.start(self) - + + def open_panel(self, *args) -> None: """Method opens a panel.""" @@ -2061,17 +2132,72 @@ def open_panel(self, *args) -> None: # anim.bind(on_complete=self._add_content) anim.bind(on_complete=self._disable_anim) anim.start(self) - + ''' + def close_panel(self) -> None: + if self._anim_playing: + return + + self._anim_playing = True + self._state = "close" + + # Store the target height BEFORE removing + target_h = self.node.height + + anim = Animation( + height=target_h, + d=self.closing_time, + t=self.closing_transition, + ) + anim.bind(on_complete=self._on_close_complete) + anim.start(self) + + def _on_close_complete(self, *args): + self._anim_playing = False + # Remove content AFTER animation finishes + if self.content in self.children: + self.remove_widget(self.content) + + def open_panel(self, *args) -> None: + if self._anim_playing: + return + + self._anim_playing = True + self._state = "open" + + # Add content first so we can measure it + if self.content not in self.children: + self.add_widget(self.content) + + # Current node height + content height + target_h = self.node.height + self.content.height + + # Temporarily keep current height so animation works + current_h = self.node.height + self.height = current_h + + anim = Animation( + height=target_h, + d=self.opening_time, + t=self.opening_transition, + ) + anim.bind(on_complete=self._disable_anim) + anim.start(self) + + def get_state(self) -> str: """Returns the state of panel. Can be `close` or `open` .""" return self._state - + ''' def add_widget(self, widget, index=0, canvas=None): if isinstance(widget, NodeDetails): self.height = widget.height return super().add_widget(widget) - + ''' + def add_widget(self, widget, index=0, canvas=None): + # Don't override height here — let the animation handle it + return super().add_widget(widget, index=index, canvas=canvas) + def _disable_anim(self, *args): self._anim_playing = False @@ -2079,6 +2205,19 @@ def _add_content(self, *args): if self.content: self.content.y = dp(72) self.add_widget(self.content) + + def filter_nodes(self): + + try: + Request = HTTPRequests.MakeRequest() + http = Request.hadapter() + req = http.get(HTTParams.PLAN_API + HTTParams.API_PLANS_NODES % self.content.uuid, auth=HTTPBasicAuth(scrtsxx.PLANUSERNAME, scrtsxx.PLANPASSWORD)) + + plan_nodes_data = req.json() if req.status_code == 200 else None + + self.mw.NodeTree.search(key=NodeKeys.NodesInfoKeys[1], value=plan_nodes_data, perfect_match=True, is_list=True) + except: + pass class NodeCarousel(MDBoxLayout): diff --git a/src/utils/coinimg/meile.png b/src/utils/coinimg/meile.png new file mode 100644 index 0000000..cab0207 Binary files /dev/null and b/src/utils/coinimg/meile.png differ diff --git a/src/utils/coinimg/v2ray.png b/src/utils/coinimg/v2ray.png new file mode 100644 index 0000000..21eed85 Binary files /dev/null and b/src/utils/coinimg/v2ray.png differ diff --git a/src/utils/coinimg/wireguard.png b/src/utils/coinimg/wireguard.png new file mode 100644 index 0000000..2c1b883 Binary files /dev/null and b/src/utils/coinimg/wireguard.png differ diff --git a/src/utils/qr.py b/src/utils/qr.py index bf0d54c..597803e 100755 --- a/src/utils/qr.py +++ b/src/utils/qr.py @@ -8,6 +8,7 @@ import hashlib from helpers.helpers import is_ecryptfs_mounted from conf.meile_config import MeileGuiConfig +from typedef.konstants import MeileColors class QRCode(): @@ -19,6 +20,81 @@ def __init__(self): self.BASEDIR = MeileGuiConfig.BASEDIR self.IMGDIR = MeileGuiConfig.IMGDIR self.MeileConfig = MeileGuiConfig() + + def generate_wg_qr_code(self, conf_path, label=None): + + + with open(conf_path, 'r') as f: + wg_config = f.read().strip() + + wg_config = wg_config.replace("127.0.0.1,", "") + + if not label: + label = path.basename(conf_path) + + + wg_logo_path = self.MeileConfig.resource_path(MeileColors.WIREGUARD_ICON) + has_logo = path.exists(wg_logo_path) + + QRcode = qrcode.QRCode( + error_correction=qrcode.constants.ERROR_CORRECT_H + ) + QRcode.add_data(wg_config) + QRcode.make() + + QRimg = QRcode.make_image( + fill_color='Black', back_color='white' + ).convert('RGB') + + if has_logo: + logo = Image.open(wg_logo_path) + basewidth = 100 + wpercent = (basewidth / float(logo.size[0])) + hsize = int(float(logo.size[1]) * float(wpercent)) + logo = logo.resize((basewidth, hsize)) + + pos = ( + (QRimg.size[0] - logo.size[0]) // 2, + (QRimg.size[1] - logo.size[1]) // 2, + ) + QRimg.paste(logo, pos) + + + border = (0, 4, 0, 30) + QRimg = ImageOps.crop(QRimg, border) + + if len(label) <= 50: + fontSize = 16 + elif len(label) <= 75: + fontSize = 12 + else: + fontSize = 11 + + background = Image.new( + 'RGBA', + (QRimg.size[0], QRimg.size[1] + 15), + (255, 255, 255, 255), + ) + robotoFont = ImageFont.truetype( + self.MeileConfig.resource_path(MeileColors.QR_FONT_FACE), + fontSize, + ) + + draw = ImageDraw.Draw(background) + _, _, w, h = draw.textbbox((0, 0), text=str(label)) + draw.text( + ((QRimg.size[0] + 15 - w) / 2, QRimg.size[1] - 2), + label, + (0, 0, 0), + font=robotoFont, + ) + + background.paste(QRimg, (0, 0)) + + safe_name = path.splitext(path.basename(conf_path))[0] + out_path = path.join(self.IMGDIR, safe_name + '_wg.png') + background.save(out_path) + return out_path def generate_qr_code(self, ADDRESS, coin): DepositCoin = coin @@ -71,10 +147,17 @@ def generate_qr_code(self, ADDRESS, coin): background.paste(QRimg, (0,0)) - if not is_ecryptfs_mounted() or coin == "dvpn": + if ADDRESS.startswith(("vmess", "vless")): + background.save(path.join(self.IMGDIR, ADDRESS[:5] + ".png")) + return path.join(self.IMGDIR, ADDRESS[0:5] + ".png") + elif not is_ecryptfs_mounted() or coin == "dvpn": background.save(path.join(self.IMGDIR, ADDRESS + ".png")) return path.join(self.IMGDIR, ADDRESS + ".png") else: hashed_address = hashlib.sha256(ADDRESS.encode()).hexdigest() background.save(path.join(self.IMGDIR, hashed_address + ".png")) - return path.join(self.IMGDIR, hashed_address + ".png") \ No newline at end of file + return path.join(self.IMGDIR, hashed_address + ".png") + + background.save(path.join(self.IMGDIR, ADDRESS + ".png")) + return path.join(self.IMGDIR, ADDRESS + ".png") +