Support new login flow via Chrome
This commit is contained in:
1 parent
88b228d38b
commit
73a2a6145f
5 files changed
+370
-307
No files matched your search
+6
-1
@@ -31,7 +31,12 @@ a = Analysis(
|
|||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
noarchive=False,
|
noarchive=False,
|
||||||
hiddenimports=[],
|
hiddenimports=[
|
||||||
|
"setuptools._distutils.log",
|
||||||
|
"setuptools._distutils.dir_util",
|
||||||
|
"setuptools._distutils.file_util",
|
||||||
|
"setuptools._distutils.archive_util",
|
||||||
|
],
|
||||||
runtime_hooks=[],
|
runtime_hooks=[],
|
||||||
cipher=block_cipher,
|
cipher=block_cipher,
|
||||||
win_no_prefer_redirects=False,
|
win_no_prefer_redirects=False,
|
||||||
|
|||||||
@@ -1,18 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import json
|
||||||
import ctypes
|
import ctypes
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
from collections import abc
|
||||||
from math import log10, ceil
|
from math import log10, ceil
|
||||||
from collections import abc, namedtuple
|
from subprocess import CREATE_NO_WINDOW
|
||||||
from tkinter.font import Font, nametofont
|
from tkinter.font import Font, nametofont
|
||||||
from functools import partial, cached_property
|
from functools import partial, cached_property
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, TypedDict, NoReturn, Generic, TYPE_CHECKING
|
|
||||||
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage
|
from tkinter import Tk, ttk, StringVar, DoubleVar, IntVar, PhotoImage
|
||||||
|
from typing import Any, Union, Tuple, TypedDict, NoReturn, Generic, TYPE_CHECKING
|
||||||
|
|
||||||
import pystray
|
import pystray
|
||||||
import win32api
|
import win32api
|
||||||
@@ -20,13 +23,18 @@ import win32con
|
|||||||
import win32gui
|
import win32gui
|
||||||
from yarl import URL
|
from yarl import URL
|
||||||
from PIL import Image as Image_module
|
from PIL import Image as Image_module
|
||||||
|
from undetected_chromedriver import ChromeOptions
|
||||||
|
from seleniumwire.undetected_chromedriver.v2 import Chrome
|
||||||
|
from selenium.common.exceptions import WebDriverException, NoSuchWindowException
|
||||||
|
|
||||||
from translate import _
|
from translate import _
|
||||||
from cache import ImageCache
|
from cache import ImageCache
|
||||||
from exceptions import ExitRequest
|
|
||||||
from utils import resource_path, Game, _T
|
from utils import resource_path, Game, _T
|
||||||
from registry import RegistryKey, ValueType
|
from registry import RegistryKey, ValueType
|
||||||
from constants import SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
|
from exceptions import MinerException, ExitRequest, LoginException
|
||||||
|
from constants import (
|
||||||
|
CLIENT_ID, SELF_PATH, FORMATTER, WS_TOPICS_LIMIT, MAX_WEBSOCKETS, WINDOW_TITLE, State
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from twitch import Twitch
|
from twitch import Twitch
|
||||||
@@ -35,9 +43,15 @@ if TYPE_CHECKING:
|
|||||||
from inventory import DropsCampaign, TimedDrop
|
from inventory import DropsCampaign, TimedDrop
|
||||||
|
|
||||||
|
|
||||||
|
TK_PADDING = Union[int, Tuple[int, int], Tuple[int, int, int], Tuple[int, int, int, int]]
|
||||||
digits = ceil(log10(WS_TOPICS_LIMIT))
|
digits = ceil(log10(WS_TOPICS_LIMIT))
|
||||||
|
|
||||||
|
|
||||||
|
######################
|
||||||
|
# GUI ELEMENTS START #
|
||||||
|
######################
|
||||||
|
|
||||||
|
|
||||||
class _TKOutputHandler(logging.Handler):
|
class _TKOutputHandler(logging.Handler):
|
||||||
def __init__(self, output: GUIManager):
|
def __init__(self, output: GUIManager):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -161,7 +175,7 @@ class PlaceholderCombobox(PlaceholderEntry, ttk.Combobox):
|
|||||||
|
|
||||||
|
|
||||||
class PaddedListbox(tk.Listbox):
|
class PaddedListbox(tk.Listbox):
|
||||||
def __init__(self, master: ttk.Widget, *args, padding: tk._Padding = (0, 0, 0, 0), **kwargs):
|
def __init__(self, master: ttk.Widget, *args, padding: TK_PADDING = (0, 0, 0, 0), **kwargs):
|
||||||
# we place the listbox inside a frame with the same background
|
# we place the listbox inside a frame with the same background
|
||||||
# this means we need to forward the 'grid' method to the frame, not the listbox
|
# this means we need to forward the 'grid' method to the frame, not the listbox
|
||||||
self._frame = tk.Frame(master)
|
self._frame = tk.Frame(master)
|
||||||
@@ -210,7 +224,7 @@ class PaddedListbox(tk.Listbox):
|
|||||||
self._frame.configure(frame_options)
|
self._frame.configure(frame_options)
|
||||||
# update padding
|
# update padding
|
||||||
if "padding" in options:
|
if "padding" in options:
|
||||||
padding: tk._Padding = options.pop("padding")
|
padding: TK_PADDING = options.pop("padding")
|
||||||
padx1: tk._ScreenUnits
|
padx1: tk._ScreenUnits
|
||||||
padx2: tk._ScreenUnits
|
padx2: tk._ScreenUnits
|
||||||
pady1: tk._ScreenUnits
|
pady1: tk._ScreenUnits
|
||||||
@@ -221,9 +235,9 @@ class PaddedListbox(tk.Listbox):
|
|||||||
padx1 = padx2 = pady1 = pady2 = padding
|
padx1 = padx2 = pady1 = pady2 = padding
|
||||||
elif len(padding) == 2:
|
elif len(padding) == 2:
|
||||||
padx1 = padx2 = padding[0]
|
padx1 = padx2 = padding[0]
|
||||||
pady1 = pady2 = padding[1] # type: ignore
|
pady1 = pady2 = padding[1]
|
||||||
elif len(padding) == 3:
|
elif len(padding) == 3:
|
||||||
padx1, padx2 = padding[0:2] # type: ignore
|
padx1, padx2 = padding[0], padding[1]
|
||||||
pady1 = pady2 = padding[2] # type: ignore
|
pady1 = pady2 = padding[2] # type: ignore
|
||||||
else:
|
else:
|
||||||
padx1, padx2, pady1, pady2 = padding # type: ignore
|
padx1, padx2, pady1, pady2 = padding # type: ignore
|
||||||
@@ -309,7 +323,9 @@ class SelectMenu(tk.Menubutton, Generic[_T]):
|
|||||||
return self._menu_options[self.cget("text")]
|
return self._menu_options[self.cget("text")]
|
||||||
|
|
||||||
|
|
||||||
# GUI ELEMENTS END / GUI DEFINITION START
|
###########################################
|
||||||
|
# GUI ELEMENTS END / GUI DEFINITION START #
|
||||||
|
###########################################
|
||||||
|
|
||||||
|
|
||||||
class StatusBar:
|
class StatusBar:
|
||||||
@@ -395,9 +411,6 @@ class WebsocketStatus:
|
|||||||
self._topics_var.set('\n'.join(topic_lines))
|
self._topics_var.set('\n'.join(topic_lines))
|
||||||
|
|
||||||
|
|
||||||
LoginData = namedtuple("LoginData", ["username", "password", "token"])
|
|
||||||
|
|
||||||
|
|
||||||
class LoginForm:
|
class LoginForm:
|
||||||
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
def __init__(self, manager: GUIManager, master: ttk.Widget):
|
||||||
self._manager = manager
|
self._manager = manager
|
||||||
@@ -409,14 +422,14 @@ class LoginForm:
|
|||||||
frame.rowconfigure(4, weight=1)
|
frame.rowconfigure(4, weight=1)
|
||||||
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
|
ttk.Label(frame, text=_("gui", "login", "labels")).grid(column=0, row=0)
|
||||||
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
|
ttk.Label(frame, textvariable=self._var, justify="center").grid(column=1, row=0)
|
||||||
self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
|
# self._login_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "username"))
|
||||||
self._login_entry.grid(column=0, row=1, columnspan=2)
|
# self._login_entry.grid(column=0, row=1, columnspan=2)
|
||||||
self._pass_entry = PlaceholderEntry(
|
# self._pass_entry = PlaceholderEntry(
|
||||||
frame, placeholder=_("gui", "login", "password"), show='•'
|
# frame, placeholder=_("gui", "login", "password"), show='•'
|
||||||
)
|
# )
|
||||||
self._pass_entry.grid(column=0, row=2, columnspan=2)
|
# self._pass_entry.grid(column=0, row=2, columnspan=2)
|
||||||
self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
|
# self._token_entry = PlaceholderEntry(frame, placeholder=_("gui", "login", "twofa_code"))
|
||||||
self._token_entry.grid(column=0, row=3, columnspan=2)
|
# self._token_entry.grid(column=0, row=3, columnspan=2)
|
||||||
self._confirm = asyncio.Event()
|
self._confirm = asyncio.Event()
|
||||||
self._button = ttk.Button(
|
self._button = ttk.Button(
|
||||||
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
|
frame, text=_("gui", "login", "button"), command=self._confirm.set, state="disabled"
|
||||||
@@ -424,31 +437,130 @@ class LoginForm:
|
|||||||
self._button.grid(column=0, row=4, columnspan=2)
|
self._button.grid(column=0, row=4, columnspan=2)
|
||||||
self.update(_("gui", "login", "logged_out"), None)
|
self.update(_("gui", "login", "logged_out"), None)
|
||||||
|
|
||||||
def clear(self, login: bool = False, password: bool = False, token: bool = False):
|
@staticmethod
|
||||||
clear_all = not login and not password and not token
|
def interceptor(request) -> None:
|
||||||
if login or clear_all:
|
if (
|
||||||
self._login_entry.clear()
|
request.method == "POST"
|
||||||
if password or clear_all:
|
and request.url == "https://passport.twitch.tv/protected_login"
|
||||||
self._pass_entry.clear()
|
):
|
||||||
if token or clear_all:
|
body = request.body.decode("utf-8")
|
||||||
self._token_entry.clear()
|
data = json.loads(body)
|
||||||
|
data["client_id"] = CLIENT_ID
|
||||||
|
request.body = json.dumps(data).encode("utf-8")
|
||||||
|
del request.headers["Content-Length"]
|
||||||
|
request.headers["Content-Length"] = str(len(request.body))
|
||||||
|
|
||||||
async def ask_login(self) -> LoginData:
|
async def ask_login(self) -> str:
|
||||||
self.update(_("gui", "login", "required"), None)
|
self.update(_("gui", "login", "required"), None)
|
||||||
self._manager.print(_("gui", "login", "request"))
|
self._manager.print(_("gui", "login", "request"))
|
||||||
self._confirm.clear()
|
self._confirm.clear()
|
||||||
self._button.config(state="normal")
|
|
||||||
# NOTE: we need this to allow for the closing window event to break the waiting here
|
# open the chrome browser on the Twitch's login page
|
||||||
done, pending = await asyncio.wait(
|
# use a separate executor to void blocking the event loop
|
||||||
[self._confirm.wait(), self._manager.wait_until_closed()],
|
loop = asyncio.get_running_loop()
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
driver = None
|
||||||
)
|
try:
|
||||||
for task in pending:
|
version_main = None
|
||||||
task.cancel()
|
for attempt in range(2):
|
||||||
if self._manager.close_requested:
|
options = ChromeOptions()
|
||||||
raise ExitRequest()
|
options.add_argument("--log-level=3")
|
||||||
self._button.config(state="disabled")
|
options.add_argument("--disable-web-security")
|
||||||
return LoginData(self._login_entry.get(), self._pass_entry.get(), self._token_entry.get())
|
options.add_argument("--allow-running-insecure-content")
|
||||||
|
options.add_argument("--lang=en")
|
||||||
|
options.add_argument("--no-sandbox")
|
||||||
|
options.add_argument("--disable-gpu")
|
||||||
|
try:
|
||||||
|
driver_coro = loop.run_in_executor(
|
||||||
|
None,
|
||||||
|
lambda: Chrome(
|
||||||
|
options=options,
|
||||||
|
# use_subprocess=True,
|
||||||
|
suppress_welcome=True,
|
||||||
|
version_main=version_main,
|
||||||
|
service_creationflags=CREATE_NO_WINDOW,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[driver_coro, self._manager.wait_until_closed()],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if self._manager.close_requested:
|
||||||
|
raise ExitRequest()
|
||||||
|
driver = next(iter(done)).result()
|
||||||
|
break
|
||||||
|
except WebDriverException as exc:
|
||||||
|
message = exc.msg
|
||||||
|
if (
|
||||||
|
message is not None
|
||||||
|
and (match := re.search(
|
||||||
|
r'Chrome version ([\d]+)\nCurrent browser version is ((\d+)\.[\d.]+)',
|
||||||
|
message,
|
||||||
|
)) is not None
|
||||||
|
):
|
||||||
|
if not attempt:
|
||||||
|
version_main = int(match.group(3))
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
raise MinerException(
|
||||||
|
"Your Chrome browser is out of date\n"
|
||||||
|
f"Required version: {match.group(1)}\n"
|
||||||
|
f"Current version: {match.group(2)}"
|
||||||
|
) from None
|
||||||
|
raise MinerException(
|
||||||
|
"An error occured while boostrapping the Chrome browser"
|
||||||
|
) from exc
|
||||||
|
assert driver is not None
|
||||||
|
driver.request_interceptor = self.interceptor
|
||||||
|
page_coro = loop.run_in_executor(None, driver.get, "https://twitch.tv/login")
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[page_coro, self._manager.wait_until_closed()],
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
for task in pending:
|
||||||
|
task.cancel()
|
||||||
|
if self._manager.close_requested:
|
||||||
|
raise ExitRequest()
|
||||||
|
# enable the login button once the page finishes opening
|
||||||
|
self._button.config(state="normal")
|
||||||
|
|
||||||
|
# auto login
|
||||||
|
# driver.find_element("id", "login-username").send_keys(username)
|
||||||
|
# driver.find_element("id", "password-input").send_keys(password)
|
||||||
|
# driver.find_element(
|
||||||
|
# "css selector", '[data-a-target="passport-login-button"]'
|
||||||
|
# ).click()
|
||||||
|
# token submit button css selectors
|
||||||
|
# Button: "screen="two_factor" target="submit_button"
|
||||||
|
# Input: <input type="text" autocomplete="one-time-code" data-a-target="tw-input"
|
||||||
|
# inputmode="numeric" pattern="[0-9]*" value="">
|
||||||
|
|
||||||
|
# wait for the user to navigate away from the URL, indicating successful login
|
||||||
|
# alternatively, they can press on the login button
|
||||||
|
while (
|
||||||
|
driver.current_url != "https://www.twitch.tv/?no-reload=true"
|
||||||
|
and not self._confirm.is_set()
|
||||||
|
):
|
||||||
|
if self._manager.close_requested:
|
||||||
|
raise ExitRequest()
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
cookies = driver.get_cookies()
|
||||||
|
for cookie in cookies:
|
||||||
|
if cookie["name"] == "auth-token":
|
||||||
|
auth_token = cookie["value"]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise LoginException("Unable to extract authorization token")
|
||||||
|
except NoSuchWindowException:
|
||||||
|
driver = None
|
||||||
|
raise LoginException("Chrome window was closed, reopening...")
|
||||||
|
finally:
|
||||||
|
self._button.config(state="disabled")
|
||||||
|
if driver is not None:
|
||||||
|
driver.quit()
|
||||||
|
return auth_token
|
||||||
|
|
||||||
def update(self, status: str, user_id: int | None):
|
def update(self, status: str, user_id: int | None):
|
||||||
if user_id is not None:
|
if user_id is not None:
|
||||||
@@ -1643,6 +1755,11 @@ class HelpTab:
|
|||||||
).grid(sticky="nsew")
|
).grid(sticky="nsew")
|
||||||
|
|
||||||
|
|
||||||
|
##########################################
|
||||||
|
# GUI DEFINITION END / GUI MANAGER START #
|
||||||
|
##########################################
|
||||||
|
|
||||||
|
|
||||||
class GUIManager:
|
class GUIManager:
|
||||||
def __init__(self, twitch: Twitch):
|
def __init__(self, twitch: Twitch):
|
||||||
self._twitch: Twitch = twitch
|
self._twitch: Twitch = twitch
|
||||||
@@ -1887,6 +2004,11 @@ class GUIManager:
|
|||||||
self.output.print(*args, **kwargs)
|
self.output.print(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
###################
|
||||||
|
# GUI MANAGER END #
|
||||||
|
###################
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Everything below is for debug purposes only
|
# Everything below is for debug purposes only
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|||||||
@@ -1,198 +1,204 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
# import an additional thing for proper PyInstaller freeze support
|
||||||
import sys
|
from multiprocessing import freeze_support
|
||||||
import signal
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import argparse
|
|
||||||
import traceback
|
|
||||||
import tkinter as tk
|
|
||||||
from tkinter import messagebox
|
|
||||||
from typing import IO, NoReturn
|
|
||||||
|
|
||||||
# pre-import 3rd party libraries, handling missing ones with an exception
|
if __name__ == "__main__":
|
||||||
try:
|
freeze_support()
|
||||||
import aiohttp # noqa
|
import io
|
||||||
except ModuleNotFoundError as exc:
|
import sys
|
||||||
raise ImportError("You have to run 'pip install aiohttp' first") from exc
|
import signal
|
||||||
try:
|
import asyncio
|
||||||
import pystray # noqa
|
import logging
|
||||||
except ModuleNotFoundError as exc:
|
import argparse
|
||||||
raise ImportError("You have to run 'pip install pystray' first") from exc
|
import traceback
|
||||||
try:
|
import tkinter as tk
|
||||||
import PIL # noqa
|
from tkinter import messagebox
|
||||||
except ModuleNotFoundError as exc:
|
from typing import IO, NoReturn
|
||||||
raise ImportError("You have to run 'pip install pillow' first") from exc
|
|
||||||
try:
|
|
||||||
import win32gui # noqa
|
|
||||||
except ModuleNotFoundError as exc:
|
|
||||||
raise ImportError("You have to run 'pip install pywin32' first") from exc
|
|
||||||
|
|
||||||
from translate import _
|
# pre-import 3rd party libraries, handling missing ones with an exception
|
||||||
from twitch import Twitch
|
try:
|
||||||
from settings import Settings
|
import aiohttp # noqa
|
||||||
from version import __version__
|
except ModuleNotFoundError as exc:
|
||||||
from exceptions import CaptchaRequired
|
raise ImportError("You have to run 'pip install aiohttp' first") from exc
|
||||||
from constants import SELF_PATH, FORMATTER, LOG_PATH, WINDOW_TITLE
|
try:
|
||||||
|
import pystray # noqa
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise ImportError("You have to run 'pip install pystray' first") from exc
|
||||||
|
try:
|
||||||
|
import PIL # noqa
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise ImportError("You have to run 'pip install pillow' first") from exc
|
||||||
|
try:
|
||||||
|
import win32gui # noqa
|
||||||
|
except ModuleNotFoundError as exc:
|
||||||
|
raise ImportError("You have to run 'pip install pywin32' first") from exc
|
||||||
|
|
||||||
|
from translate import _
|
||||||
|
from twitch import Twitch
|
||||||
|
from settings import Settings
|
||||||
|
from version import __version__
|
||||||
|
from exceptions import CaptchaRequired
|
||||||
|
from constants import SELF_PATH, FORMATTER, LOG_PATH, WINDOW_TITLE
|
||||||
|
|
||||||
class Parser(argparse.ArgumentParser):
|
class Parser(argparse.ArgumentParser):
|
||||||
def __init__(self, *args, **kwargs) -> None:
|
def __init__(self, *args, **kwargs) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._message: io.StringIO = io.StringIO()
|
self._message: io.StringIO = io.StringIO()
|
||||||
|
|
||||||
def _print_message(self, message: str, file: IO[str] | None = None) -> None:
|
def _print_message(self, message: str, file: IO[str] | None = None) -> None:
|
||||||
self._message.write(message)
|
self._message.write(message)
|
||||||
# print(message, file=self._message)
|
# print(message, file=self._message)
|
||||||
|
|
||||||
def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
|
def exit(self, status: int = 0, message: str | None = None) -> NoReturn:
|
||||||
try:
|
try:
|
||||||
super().exit(status, message) # sys.exit(2)
|
super().exit(status, message) # sys.exit(2)
|
||||||
finally:
|
finally:
|
||||||
messagebox.showerror("Argument Parser Error", self._message.getvalue())
|
messagebox.showerror("Argument Parser Error", self._message.getvalue())
|
||||||
|
|
||||||
|
class ParsedArgs(argparse.Namespace):
|
||||||
|
_verbose: int
|
||||||
|
_debug_ws: bool
|
||||||
|
_debug_gql: bool
|
||||||
|
log: bool
|
||||||
|
tray: bool
|
||||||
|
no_run_check: bool
|
||||||
|
|
||||||
class ParsedArgs(argparse.Namespace):
|
# TODO: replace int with union of literal values once typeshed updates
|
||||||
_verbose: int
|
@property
|
||||||
_debug_ws: bool
|
def logging_level(self) -> int:
|
||||||
_debug_gql: bool
|
return {
|
||||||
log: bool
|
0: logging.ERROR,
|
||||||
tray: bool
|
1: logging.WARNING,
|
||||||
no_run_check: bool
|
2: logging.INFO,
|
||||||
|
3: logging.DEBUG,
|
||||||
|
}[min(self._verbose, 3)]
|
||||||
|
|
||||||
# TODO: replace int with union of literal values once typeshed updates
|
@property
|
||||||
@property
|
def debug_ws(self) -> int:
|
||||||
def logging_level(self) -> int:
|
"""
|
||||||
return {
|
If the debug flag is True, return DEBUG.
|
||||||
0: logging.ERROR,
|
If the main logging level is DEBUG, return INFO to avoid seeing raw messages.
|
||||||
1: logging.WARNING,
|
Otherwise, return NOTSET to inherit the global logging level.
|
||||||
2: logging.INFO,
|
"""
|
||||||
3: logging.DEBUG,
|
if self._debug_ws:
|
||||||
}[min(self._verbose, 3)]
|
return logging.DEBUG
|
||||||
|
elif self._verbose >= 3:
|
||||||
|
return logging.INFO
|
||||||
|
return logging.NOTSET
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def debug_ws(self) -> int:
|
def debug_gql(self) -> int:
|
||||||
"""
|
if self._debug_gql:
|
||||||
If the debug flag is True, return DEBUG.
|
return logging.DEBUG
|
||||||
If the main logging level is DEBUG, return INFO to avoid seeing raw messages.
|
elif self._verbose >= 3:
|
||||||
Otherwise, return NOTSET to inherit the global logging level.
|
return logging.INFO
|
||||||
"""
|
return logging.NOTSET
|
||||||
if self._debug_ws:
|
|
||||||
return logging.DEBUG
|
|
||||||
elif self._verbose >= 3:
|
|
||||||
return logging.INFO
|
|
||||||
return logging.NOTSET
|
|
||||||
|
|
||||||
@property
|
# handle input parameters
|
||||||
def debug_gql(self) -> int:
|
# NOTE: parser output is shown via message box
|
||||||
if self._debug_gql:
|
# we also need a dummy invisible window for the parser
|
||||||
return logging.DEBUG
|
root = tk.Tk()
|
||||||
elif self._verbose >= 3:
|
root.overrideredirect(True)
|
||||||
return logging.INFO
|
root.withdraw()
|
||||||
return logging.NOTSET
|
root.update()
|
||||||
|
parser = Parser(
|
||||||
|
SELF_PATH.name,
|
||||||
# handle input parameters
|
description="A program that allows you to mine timed drops on Twitch.",
|
||||||
# NOTE: parser output is shown via message box
|
|
||||||
# we also need a dummy invisible window for the parser
|
|
||||||
root = tk.Tk()
|
|
||||||
root.overrideredirect(True)
|
|
||||||
root.withdraw()
|
|
||||||
root.update()
|
|
||||||
parser = Parser(
|
|
||||||
SELF_PATH.name,
|
|
||||||
description="A program that allows you to mine timed drops on Twitch.",
|
|
||||||
)
|
|
||||||
parser.add_argument("--version", action="version", version=f"v{__version__}")
|
|
||||||
parser.add_argument("-v", dest="_verbose", action="count", default=0)
|
|
||||||
parser.add_argument("--tray", action="store_true")
|
|
||||||
parser.add_argument("--log", action="store_true")
|
|
||||||
# undocumented debug args
|
|
||||||
parser.add_argument(
|
|
||||||
"--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS
|
|
||||||
)
|
|
||||||
parser.add_argument("--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS)
|
|
||||||
parser.add_argument("--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS)
|
|
||||||
args = parser.parse_args(namespace=ParsedArgs())
|
|
||||||
# load settings
|
|
||||||
try:
|
|
||||||
settings = Settings(args)
|
|
||||||
except Exception:
|
|
||||||
messagebox.showerror(
|
|
||||||
"Settings error",
|
|
||||||
f"There was an error while loading the settings file:\n\n{traceback.format_exc()}"
|
|
||||||
)
|
)
|
||||||
sys.exit(4)
|
parser.add_argument("--version", action="version", version=f"v{__version__}")
|
||||||
# dummy window isn't needed anymore
|
parser.add_argument("-v", dest="_verbose", action="count", default=0)
|
||||||
root.destroy()
|
parser.add_argument("--tray", action="store_true")
|
||||||
|
parser.add_argument("--log", action="store_true")
|
||||||
|
# undocumented debug args
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-run-check", dest="no_run_check", action="store_true", help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-ws", dest="_debug_ws", action="store_true", help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--debug-gql", dest="_debug_gql", action="store_true", help=argparse.SUPPRESS
|
||||||
|
)
|
||||||
|
args = parser.parse_args(namespace=ParsedArgs())
|
||||||
|
# load settings
|
||||||
|
try:
|
||||||
|
settings = Settings(args)
|
||||||
|
except Exception:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Settings error",
|
||||||
|
f"There was an error while loading the settings file:\n\n{traceback.format_exc()}"
|
||||||
|
)
|
||||||
|
sys.exit(4)
|
||||||
|
# dummy window isn't needed anymore
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
# check if we're not already running
|
# check if we're not already running
|
||||||
try:
|
try:
|
||||||
exists = win32gui.FindWindow(None, WINDOW_TITLE)
|
exists = win32gui.FindWindow(None, WINDOW_TITLE)
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# we're not on Windows - continue
|
# we're not on Windows - continue
|
||||||
exists = False
|
exists = False
|
||||||
if exists and not settings.no_run_check:
|
if exists and not settings.no_run_check:
|
||||||
# already running - exit
|
# already running - exit
|
||||||
sys.exit(3)
|
sys.exit(3)
|
||||||
|
|
||||||
# set language
|
# set language
|
||||||
try:
|
try:
|
||||||
_.set_language(settings.language)
|
_.set_language(settings.language)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
# this language doesn't exist - stick to English
|
# this language doesn't exist - stick to English
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# handle logging stuff
|
# handle logging stuff
|
||||||
if settings.logging_level > logging.DEBUG:
|
if settings.logging_level > logging.DEBUG:
|
||||||
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
# redirect the root logger into a NullHandler, effectively ignoring all logging calls
|
||||||
# that aren't ours. This always runs, unless the main logging level is DEBUG or lower.
|
# that aren't ours. This always runs, unless the main logging level is DEBUG or lower.
|
||||||
logging.getLogger().addHandler(logging.NullHandler())
|
logging.getLogger().addHandler(logging.NullHandler())
|
||||||
logger = logging.getLogger("TwitchDrops")
|
logger = logging.getLogger("TwitchDrops")
|
||||||
logger.setLevel(settings.logging_level)
|
logger.setLevel(settings.logging_level)
|
||||||
if settings.log:
|
if settings.log:
|
||||||
handler = logging.FileHandler(LOG_PATH)
|
handler = logging.FileHandler(LOG_PATH)
|
||||||
handler.setFormatter(FORMATTER)
|
handler.setFormatter(FORMATTER)
|
||||||
logger.addHandler(handler)
|
logger.addHandler(handler)
|
||||||
logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql)
|
logging.getLogger("TwitchDrops.gql").setLevel(settings.debug_gql)
|
||||||
logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws)
|
logging.getLogger("TwitchDrops.websocket").setLevel(settings.debug_ws)
|
||||||
|
|
||||||
# client run
|
# client run
|
||||||
exit_status = 0
|
exit_status = 0
|
||||||
loop = asyncio.new_event_loop()
|
loop = asyncio.new_event_loop()
|
||||||
asyncio.set_event_loop(loop)
|
asyncio.set_event_loop(loop)
|
||||||
client = Twitch(settings)
|
client = Twitch(settings)
|
||||||
signal.signal(signal.SIGINT, lambda *_: client.gui.close())
|
signal.signal(signal.SIGINT, lambda *_: client.gui.close())
|
||||||
signal.signal(signal.SIGTERM, lambda *_: client.gui.close())
|
signal.signal(signal.SIGTERM, lambda *_: client.gui.close())
|
||||||
try:
|
try:
|
||||||
loop.run_until_complete(client.run())
|
loop.run_until_complete(client.run())
|
||||||
except CaptchaRequired:
|
except CaptchaRequired:
|
||||||
exit_status = 1
|
exit_status = 1
|
||||||
msg = _("error", "captcha")
|
msg = _("error", "captcha")
|
||||||
client.prevent_close()
|
client.prevent_close()
|
||||||
client.print(msg)
|
client.print(msg)
|
||||||
except Exception:
|
except Exception:
|
||||||
exit_status = 1
|
exit_status = 1
|
||||||
msg = "Fatal error encountered:\n"
|
msg = "Fatal error encountered:\n"
|
||||||
client.prevent_close()
|
client.prevent_close()
|
||||||
client.print(msg)
|
client.print(msg)
|
||||||
client.print(traceback.format_exc())
|
client.print(traceback.format_exc())
|
||||||
finally:
|
finally:
|
||||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||||
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||||
client.print(_("gui", "status", "exiting"))
|
client.print(_("gui", "status", "exiting"))
|
||||||
loop.run_until_complete(client.shutdown())
|
loop.run_until_complete(client.shutdown())
|
||||||
if not client.gui.close_requested:
|
if not client.gui.close_requested:
|
||||||
client.print(_("status", "terminated"))
|
client.print(_("status", "terminated"))
|
||||||
client.gui.status.update(_("gui", "status", "terminated"))
|
client.gui.status.update(_("gui", "status", "terminated"))
|
||||||
loop.run_until_complete(client.gui.wait_until_closed())
|
loop.run_until_complete(client.gui.wait_until_closed())
|
||||||
# save the application state
|
# save the application state
|
||||||
# NOTE: we have to do it after wait_until_closed,
|
# NOTE: we have to do it after wait_until_closed,
|
||||||
# because the user can alter some settings between app termination and closing the window
|
# because the user can alter some settings between app termination and closing the window
|
||||||
client.save(force=True)
|
client.save(force=True)
|
||||||
client.gui.stop()
|
client.gui.stop()
|
||||||
client.gui.close_window()
|
client.gui.close_window()
|
||||||
loop.run_until_complete(loop.shutdown_asyncgens())
|
loop.run_until_complete(loop.shutdown_asyncgens())
|
||||||
loop.close()
|
loop.close()
|
||||||
sys.exit(exit_status)
|
sys.exit(exit_status)
|
||||||
@@ -2,3 +2,6 @@ aiohttp>2.0,<4.0
|
|||||||
Pillow
|
Pillow
|
||||||
pystray
|
pystray
|
||||||
pywin32
|
pywin32
|
||||||
|
selenium-wire
|
||||||
|
# use a fork that has an extra option of hiding the unneeded Chrome's console cmd window
|
||||||
|
git+https://github.com/sebdelsol/undetected-chromedriver
|
||||||
@@ -22,9 +22,7 @@ from gui import GUIManager
|
|||||||
from channel import Channel
|
from channel import Channel
|
||||||
from websocket import WebsocketPool
|
from websocket import WebsocketPool
|
||||||
from inventory import DropsCampaign
|
from inventory import DropsCampaign
|
||||||
from exceptions import (
|
from exceptions import MinerException, ExitRequest, LoginException, ReloadRequest, RequestInvalid
|
||||||
MinerException, LoginException, CaptchaRequired, ExitRequest, ReloadRequest, RequestInvalid
|
|
||||||
)
|
|
||||||
from utils import (
|
from utils import (
|
||||||
CHARS_HEX_LOWER,
|
CHARS_HEX_LOWER,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -103,89 +101,18 @@ class _AuthState:
|
|||||||
async def _login(self) -> str:
|
async def _login(self) -> str:
|
||||||
logger.debug("Login flow started")
|
logger.debug("Login flow started")
|
||||||
login_form: LoginForm = self._twitch.gui.login
|
login_form: LoginForm = self._twitch.gui.login
|
||||||
gui_print = self._twitch.gui.print
|
|
||||||
|
|
||||||
payload: JsonType = {
|
for attempt in range(1, 6): # 5 attempts
|
||||||
"client_id": CLIENT_ID,
|
try:
|
||||||
"undelete_user": False,
|
self.access_token = await login_form.ask_login()
|
||||||
"remember_me": True,
|
break
|
||||||
# 'force_twitchguard': False,
|
except LoginException as exc:
|
||||||
}
|
if attempt < 5:
|
||||||
|
self._twitch.gui.print(exc.args[0])
|
||||||
while True:
|
continue
|
||||||
username, password, token = await login_form.ask_login()
|
raise # reraise on and past the 5th attempt
|
||||||
payload["username"] = username
|
logger.debug("Access token granted")
|
||||||
payload["password"] = password
|
return self.access_token
|
||||||
# remove stale 2FA tokens, if present
|
|
||||||
payload.pop("authy_token", None)
|
|
||||||
payload.pop("twitchguard_code", None)
|
|
||||||
for attempt in range(2):
|
|
||||||
async with self._twitch.request(
|
|
||||||
"POST", "https://passport.twitch.tv/login", json=payload
|
|
||||||
) as response:
|
|
||||||
login_response: JsonType = await response.json()
|
|
||||||
|
|
||||||
# Feed this back in to avoid running into CAPTCHA if possible
|
|
||||||
if "captcha_proof" in login_response:
|
|
||||||
payload["captcha"] = {"proof": login_response["captcha_proof"]}
|
|
||||||
|
|
||||||
# Error handling
|
|
||||||
if "error_code" in login_response:
|
|
||||||
error_code: int = login_response["error_code"]
|
|
||||||
logger.debug(f"Login error code: {error_code}")
|
|
||||||
if error_code in (1000, 5027):
|
|
||||||
# we've failed bois
|
|
||||||
session = await self._twitch.get_session()
|
|
||||||
jar = cast(aiohttp.CookieJar, session.cookie_jar)
|
|
||||||
assert BASE_URL.host is not None
|
|
||||||
jar.clear_domain(BASE_URL.host)
|
|
||||||
logger.debug("Login failed due to CAPTCHA")
|
|
||||||
raise CaptchaRequired()
|
|
||||||
elif error_code == 3001:
|
|
||||||
# wrong password you dummy
|
|
||||||
logger.debug("Login failed due to incorrect username or password")
|
|
||||||
gui_print(_("login", "incorrect_login_pass"))
|
|
||||||
login_form.clear(password=True)
|
|
||||||
break
|
|
||||||
elif error_code in (
|
|
||||||
3012, # Invalid authy token
|
|
||||||
3023, # Invalid email code
|
|
||||||
):
|
|
||||||
logger.debug("Login failed due to incorrect 2FA code")
|
|
||||||
if error_code == 3023:
|
|
||||||
gui_print(_("login", "incorrect_email_code"))
|
|
||||||
else:
|
|
||||||
gui_print(_("login", "incorrect_twofa_code"))
|
|
||||||
login_form.clear(token=True)
|
|
||||||
break
|
|
||||||
elif error_code in (
|
|
||||||
3011, # Authy token needed
|
|
||||||
3022, # Email code needed
|
|
||||||
):
|
|
||||||
# 2FA handling
|
|
||||||
logger.debug("2FA token required")
|
|
||||||
email = error_code == 3022
|
|
||||||
if not token:
|
|
||||||
# user didn't provide a token, so ask them for it
|
|
||||||
if email:
|
|
||||||
gui_print(_("login", "email_code_required"))
|
|
||||||
else:
|
|
||||||
gui_print(_("login", "twofa_code_required"))
|
|
||||||
break
|
|
||||||
if email:
|
|
||||||
payload["twitchguard_code"] = token
|
|
||||||
else:
|
|
||||||
payload["authy_token"] = token
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
raise LoginException(login_response["error"])
|
|
||||||
# Success handling
|
|
||||||
if "access_token" in login_response:
|
|
||||||
# we're in bois
|
|
||||||
self.access_token = cast(str, login_response["access_token"])
|
|
||||||
logger.debug("Access token granted")
|
|
||||||
login_form.clear()
|
|
||||||
return self.access_token
|
|
||||||
|
|
||||||
def gql_headers(self, *, integrity: bool) -> JsonType:
|
def gql_headers(self, *, integrity: bool) -> JsonType:
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
Reference in new issue
Block a user