diff --git a/icon.ico b/assets/notifier/icon.ico similarity index 100% rename from icon.ico rename to assets/notifier/icon.ico diff --git a/main.py b/main.py index dfbd38d..dff0b04 100644 --- a/main.py +++ b/main.py @@ -1,13 +1,16 @@ import json import platform import os -import sys import httpx from src import App +from src.operating_systems.operating_system import OperatingSystem +from src.operating_systems.windows_operating_system import WindowsOperatingSystem +from src.operating_systems.linux_operating_system import LinuxOperatingSystem +from src.notifiers.notifier import Notifier +from src.notifiers.windows_notifier import WindowsNotifier from src import Logger from src import __version__, __title__, __clientid___ - def prepare_environment(): try: raw_settings = json.load(open("settings.json")) @@ -54,26 +57,23 @@ def prepare_environment(): if __name__ == "__main__": try: - if platform.system() != "Windows": - Logger.write(message="Sorry! only supports Windows.", level="ERROR") - exit() + operating_system: str = platform.system() + system: OperatingSystem = None + notifier: Notifier = None + match operating_system: + case "Windows": + system = WindowsOperatingSystem() + notifier = WindowsNotifier() + case "Linux": + system = LinuxOperatingSystem() + case _: + Logger.write(message="Sorry! only supports Windows and Linux.", level="ERROR") + exit() settings = prepare_environment() - # using conhost to allow the functionality of hidding and showing the console. - if len(sys.argv) == 1 and sys.argv[0] != "main.py": - found = [] - for file in os.listdir(os.getcwd()): - if file.endswith(".exe"): - found.append(file) - file = found[0] - # print(f'cmd /k {os.environ["SYSTEMDRIVE"]}\\Windows\\System32\\conhost.exe ' + cmd) - os.system( - f'cmd /c {os.environ["SYSTEMDRIVE"]}\\Windows\\System32\\conhost.exe {os.path.join(os.getcwd(),file)} True' - ) - exit() - os.system( - f"cmd /c taskkill /IM WindowsTerminal.exe /IM cmd.exe /F" - ) # removed /IM cmd.exe in case that causes problems for windows 10. Windows 11 requires starting a new task and killing windows terminal. + system.hide_console_process() app = App( + operating_system=system, + notifier=notifier, client_id=settings["client_id"], version=__version__, title=__title__, @@ -85,4 +85,4 @@ def prepare_environment(): app.run() except KeyboardInterrupt: Logger.write(message="User interrupted.") - app.stop() + app.stop() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 81a900a..534e192 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,15 +1,18 @@ anyio==4.3.0 certifi==2024.2.2 +cx_Freeze==7.1.1 exceptiongroup==1.2.1 +filelock==3.15.3 h11==0.14.0 httpcore==1.0.5 httpx==0.27.0 idna==3.7 infi==0.0.1 infi.systray==0.1.12 -pypiwin32==223 +patchelf==0.17.2.1 +pypiwin32==223; platform_system == "Windows" pypresence==4.2.1 -pywin32==306 +pywin32==306; platform_system == "Windows" sniffio==1.3.1 typing_extensions==4.11.0 -websocket-client==1.6.0 +websocket-client==1.6.0 \ No newline at end of file diff --git a/settings.json b/settings.json index 253b9ce..4695c15 100644 --- a/settings.json +++ b/settings.json @@ -1 +1 @@ -{"first_run": true, "client_id": "828083725790609459", "profile_name": "Default", "refresh_rate": 1, "display_time_left": true} \ No newline at end of file +{"first_run": false, "client_id": "828083725790609459", "profile_name": "Default", "refresh_rate": 1, "display_time_left": true} \ No newline at end of file diff --git a/setup.py b/setup.py index aecb6e7..787f839 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ "main.py", copyright="© 2020 - 2023 by " + __author__ + " - All rights reserved", icon="assets/new_logo.ico", - target_name="Youtube Music Rich Presence.exe", + target_name="Youtube Music Rich Presence", shortcut_name="Youtube Music RPC", ) ], diff --git a/src/app.py b/src/app.py index 7796302..e8078cc 100644 --- a/src/app.py +++ b/src/app.py @@ -1,23 +1,17 @@ import time import os -from infi.systray import SysTrayIcon from .presence import Presence from .logger import Logger +from .notifiers.notifier import Notifier +from .system_tray.system_tray import SystemTray from .tab import Tab -from .notification import ToastNotifier -import win32con -import win32gui -import ctypes +from .operating_systems.operating_system import OperatingSystem from .utils import ( remote_debugging, - run_browser, - get_default_browser, get_browser_tabs, - find_windows_process, ) DISCORD_STATUS_LIMIT = 15 -toast = ToastNotifier() class App: @@ -35,11 +29,16 @@ class App: "useTimeLeft", "showen", "systray", - "silent" + "silent", + "__operating_system", + "notifier", ) def __init__( self, + operating_system: OperatingSystem, + notifier: Notifier, + systray: SystemTray = None, client_id: str = "", version: str = None, title: str = None, @@ -61,6 +60,9 @@ def __init__( self.useTimeLeft = useTimeLeft self.silent = False self.__profileName = profileName + self.__operating_system = operating_system + self.notifier = notifier + self.systray = systray def __handle_exception(self, exc: Exception) -> None: Logger.write(message=exc, level="ERROR", origin=self) @@ -71,7 +73,7 @@ def sync(self) -> None: status = self.__presence.connect() if not status: raise Exception("Can't connect to Discord.") - self.__browser = get_default_browser() + self.__browser = self.__operating_system.get_default_browser() if not self.__browser: raise Exception("Can't find default browser in your system.") if not self.__browser["chromium"]: @@ -85,7 +87,7 @@ def sync(self) -> None: def stop(self) -> None: if self.connected == True: self.connected = False - self.systray.shutdown() + self.systray.stop() self.__presence.close() Logger.write(message="stopped.", origin=self) @@ -108,29 +110,6 @@ def current_playing_tab(self, tabs: list) -> dict: if tab.pause: return tab return None - - def hideWindow(self, systray): - if self.showen is True: - self.showen = False - window = ctypes.windll.kernel32.GetConsoleWindow() - win32gui.ShowWindow(window, win32con.SW_HIDE) - elif self.showen is False: - self.showen = True - window = ctypes.windll.kernel32.GetConsoleWindow() - win32gui.ShowWindow(window, win32con.SW_SHOW) - - def update(self, systray): - toast = ToastNotifier() - try: - toast.show_toast( - "Coming soon!", - "This feature isn't currently avaiable yet.", - duration = 5, - icon_path = f"{os.path.join(os.getcwd(), 'icon.ico')}", - threaded = True, - ) - except TypeError: - pass def on_quit_callback(self, systray): if self.connected == True: @@ -141,18 +120,12 @@ def on_quit_callback(self, systray): def run(self) -> None: last_updated_time: int = 1 try: - menu_options = (("Hide/Show Console", None, self.hideWindow), ("Force Update", None, self.update)) - self.systray = SysTrayIcon("./icon.ico", "YT Music RPC", menu_options, on_quit=self.on_quit_callback) - self.systray.start() if not self.connected: raise RuntimeError("Not connected.") - browser_process = self.__browser["process"]["win32"] - browser_running = find_windows_process( - browser_process, self.__browser["name"] - ) + browser_running = self.__operating_system.is_browser_running() if not remote_debugging() and browser_running: Logger.write( - message=f"Detected browser running ({browser_process}) without remote debugging enabled.", + message=f"Detected browser running ({self.__operating_system.get_browser_process_name()}) without remote debugging enabled.", level="WARNING", origin=self, ) @@ -163,7 +136,7 @@ def run(self) -> None: level="WARNING", origin=self, ) - run_browser(self.__browser, self.__profileName) + self.__operating_system.run_browser_with_debugging_server(self.__profileName) else: Logger.write( message="Remote debugging is enabled, connected successfully.", @@ -238,15 +211,11 @@ def run(self) -> None: silent=self.silent ) - if not self.silent: + if not self.silent and self.notifier is not None: try: - toast.show_toast( - "Now Playing!", - f"{self.last_tab.title} by {self.last_tab.artist}", - duration = 3, - icon_path = f"{os.path.join(os.getcwd(), 'icon.ico')}", - threaded = True, - ) + self.notifier.notify("Now Playing!", + f"{self.last_tab.title} by {self.last_tab.artist}" + ) except TypeError: pass diff --git a/src/notifiers/__init__.py b/src/notifiers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/notifiers/notifier.py b/src/notifiers/notifier.py new file mode 100644 index 0000000..e0b10d2 --- /dev/null +++ b/src/notifiers/notifier.py @@ -0,0 +1,5 @@ +from abc import ABC, abstractmethod +class Notifier(ABC): + @abstractmethod + def notify(self) -> None: + pass \ No newline at end of file diff --git a/src/notification.py b/src/notifiers/toast_notifier.py similarity index 79% rename from src/notification.py rename to src/notifiers/toast_notifier.py index 2e470c7..5a76c06 100644 --- a/src/notification.py +++ b/src/notifiers/toast_notifier.py @@ -17,33 +17,36 @@ from pkg_resources import resource_filename # 3rd party modules -from win32api import GetModuleHandle -from win32api import PostQuitMessage -from win32con import CW_USEDEFAULT -from win32con import IDI_APPLICATION -from win32con import IMAGE_ICON -from win32con import LR_DEFAULTSIZE -from win32con import LR_LOADFROMFILE -from win32con import WM_DESTROY -from win32con import WM_USER -from win32con import WS_OVERLAPPED -from win32con import WS_SYSMENU -from win32gui import CreateWindow -from win32gui import DestroyWindow -from win32gui import LoadIcon -from win32gui import LoadImage -from win32gui import NIF_ICON -from win32gui import NIF_INFO -from win32gui import NIF_MESSAGE -from win32gui import NIF_TIP -from win32gui import NIM_ADD -from win32gui import NIM_DELETE -from win32gui import NIM_MODIFY -from win32gui import RegisterClass -from win32gui import UnregisterClass -from win32gui import Shell_NotifyIcon -from win32gui import UpdateWindow -from win32gui import WNDCLASS +try: + from win32api import GetModuleHandle + from win32api import PostQuitMessage + from win32con import CW_USEDEFAULT + from win32con import IDI_APPLICATION + from win32con import IMAGE_ICON + from win32con import LR_DEFAULTSIZE + from win32con import LR_LOADFROMFILE + from win32con import WM_DESTROY + from win32con import WM_USER + from win32con import WS_OVERLAPPED + from win32con import WS_SYSMENU + from win32gui import CreateWindow + from win32gui import DestroyWindow + from win32gui import LoadIcon + from win32gui import LoadImage + from win32gui import NIF_ICON + from win32gui import NIF_INFO + from win32gui import NIF_MESSAGE + from win32gui import NIF_TIP + from win32gui import NIM_ADD + from win32gui import NIM_DELETE + from win32gui import NIM_MODIFY + from win32gui import RegisterClass + from win32gui import UnregisterClass + from win32gui import Shell_NotifyIcon + from win32gui import UpdateWindow + from win32gui import WNDCLASS +except ImportError: + pass # ############################################################################ # ########### Classes ############## diff --git a/src/notifiers/windows_notifier.py b/src/notifiers/windows_notifier.py new file mode 100644 index 0000000..044a701 --- /dev/null +++ b/src/notifiers/windows_notifier.py @@ -0,0 +1,19 @@ +import os +from .toast_notifier import ToastNotifier +from .notifier import Notifier + +class WindowsNotifier(Notifier): + def __init__(self): + self.toast = ToastNotifier() + + def notify(self, title: str, subtitle: str) -> None: + assets_path = os.path.join(os.getcwd(), 'assets') + notifier_path = os.path.join(assets_path, 'notifier') + icon_path = os.path.join(notifier_path, 'icon.ico') + self.toast.show_toast( + title, + subtitle, + duration = 3, + icon_path = icon_path,#os.path.join(os.getcwd(), 'icon.ico'), + threaded = True, + ) diff --git a/src/operating_systems/__init__.py b/src/operating_systems/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/operating_systems/linux_operating_system.py b/src/operating_systems/linux_operating_system.py new file mode 100644 index 0000000..87bac58 --- /dev/null +++ b/src/operating_systems/linux_operating_system.py @@ -0,0 +1,69 @@ +import os +import subprocess as sp +from src.operating_systems.operating_system import OperatingSystem +from ..utils import find_browser_by_process, run_browser + +class LinuxOperatingSystem(OperatingSystem): + + __slots__ = ( + "browser_process_name", + "browser_executable_path", + ) + + def get_default_browser(self) -> dict: + default_browser_capture = sp.run( + ["xdg-settings", "get", "default-web-browser"], + capture_output=True, + text=True + ) + if default_browser_capture.stderr != "": + raise Exception("Can't find default browser") + default_browser = default_browser_capture.stdout.split(".")[0] + found_browser = find_browser_by_process("linux", default_browser) + self.browser_process_name = found_browser["process"]['linux'] + if not found_browser: + raise Exception("Unsupported browser, sorry") + + browser_path_capture = sp.run( + ["which", default_browser], + capture_output=True, + text=True + ) + if browser_path_capture.stderr != "": + raise Exception("Cannot find browser executable location") + + found_browser["path"] = browser_path_capture.stdout.split("\n")[0] + self.browser_executable_path = browser_path_capture.stdout.split("\n")[0] + return found_browser + + def is_browser_running(self) -> bool: + # FIXME: When installing chrome through .deb its process name will be "chrome" and not "google-chrome" + # Perhaps change ["process"][key] to list in BROWSER array + process_name = self.browser_process_name + if process_name == "google-chrome": + process_name = "chrome" + all_processes = sp.run(['ps', '-e'], capture_output=True) + chrome_processes = sp.run(['grep', process_name], input=all_processes.stdout, capture_output=True) + # FIXME: only tested on chrome, 'chrome_crashpad' will linger after the browser closed + filtered_chrome_processes = sp.run(['grep', '-v', 'chrome_crashpad'], + input=chrome_processes.stdout, + capture_output=True).stdout.decode() + if filtered_chrome_processes is not None and filtered_chrome_processes != "": + return True + return False + + def run_browser_with_debugging_server(self, profile_name: str) -> None: + user_home = os.path.expanduser('~') + profile_path = os.path.join(user_home, '.config', 'google-chrome', 'Default') + run_browser( + self.browser_executable_path, + profile_name, + profile_path + ) + + def get_browser_process_name(self) -> bool: + return self.browser_process_name + + def hide_console_process(self) -> str: + # TODO: Implement + pass \ No newline at end of file diff --git a/src/operating_systems/operating_system.py b/src/operating_systems/operating_system.py new file mode 100644 index 0000000..3a0980c --- /dev/null +++ b/src/operating_systems/operating_system.py @@ -0,0 +1,34 @@ +from abc import ABC, abstractmethod + +class OperatingSystem(ABC): + """ + Abstract class for functionality + that differs between operating systems + """ + + @abstractmethod + def get_default_browser(self) -> dict: + """ + Returns the user' default browser metadata in a dictionary + """ + pass + + @abstractmethod + def is_browser_running(self) -> bool: + pass + + @abstractmethod + def run_browser_with_debugging_server(self, profile_name: str) -> None: + """ + Starts a browser with debugging enabled. + profile_name should correspond with the user profile + """ + pass + + @abstractmethod + def get_browser_process_name(self) -> str: + pass + + @abstractmethod + def hide_console_process(self) -> str: + pass \ No newline at end of file diff --git a/src/operating_systems/windows_operating_system.py b/src/operating_systems/windows_operating_system.py new file mode 100644 index 0000000..1469846 --- /dev/null +++ b/src/operating_systems/windows_operating_system.py @@ -0,0 +1,77 @@ +import sys +import os +try: + import winreg as wr +except ImportError: + pass +from src.operating_systems.operating_system import OperatingSystem +from ..utils import find_browser, run_browser + +class WindowsOperatingSystem(OperatingSystem): + + __slots__ = ( + "browser_process_name", + "browser_executable_path", + "browser_profile_path", + ) + + def get_default_browser(self) -> bool: + progid = wr.QueryValueEx( + wr.OpenKey( + wr.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + ), + "ProgId", + )[0] + if not progid: + raise Exception("Can't find default browser") + browser = find_browser("name", progid.split(".")[0]) + if not browser: + raise Exception("Unsupported browser, sorry") + browser["path"] = wr.QueryValueEx( + wr.OpenKey(wr.HKEY_CLASSES_ROOT, progid + "\shell\open\command"), "" + )[0].split('"')[1] + self.browser_executable_path = browser["path"] + self.browser_process_name = browser["process"]["win32"] + self.browser_profile_path = browser["profilePath"] + return browser + + def is_browser_running(self) -> bool: + #FIXME: This implementation is broken (see issue #41) + return False + """ + res = sp.check_output( + "WMIC PROCESS WHERE \"name='{process_name}'\" GET Execu wtablePath", + stderr=sp.PIPE, + ).decode() + return bool(re.search(ref, res)) + """ + + def run_browser_with_debugging_server(self, profile_name:str) -> None: + full_profile_path = f'{os.environ["systemdrive"]}\\users\\{os.getenv("username") + self.browser_profile_path + profile_name}' + run_browser( + self.browser_executable_path, + profile_name, + full_profile_path + ) + + def get_browser_process_name(self) -> str: + return self.browser_process_name + + def hide_console_process(self) -> str: + # FIXME: Ugly implementation, there are alternatives for this + # using conhost to allow the functionality of hidding and showing the console. + if len(sys.argv) == 1 and not sys.argv[0].endswith("main.py"): + found = [] + for file in os.listdir(os.getcwd()): + if file.endswith(".exe"): + found.append(file) + file = found[0] + # print(f'cmd /k {os.environ["SYSTEMDRIVE"]}\\Windows\\System32\\conhost.exe ' + cmd) + os.system( + f'cmd /c {os.environ["SYSTEMDRIVE"]}\\Windows\\System32\\conhost.exe {os.path.join(os.getcwd(),file)} True' + ) + exit() + os.system( + f"cmd /c taskkill /IM WindowsTerminal.exe /IM cmd.exe /F" + ) # removed /IM cmd.exe in case that causes problems for windows 10. Windows 11 requires starting a new task and killing windows terminal. diff --git a/src/system_tray/system_tray.py b/src/system_tray/system_tray.py new file mode 100644 index 0000000..2311f8a --- /dev/null +++ b/src/system_tray/system_tray.py @@ -0,0 +1,8 @@ +from abc import ABC, abstractmethod +class SystemTray(ABC): + @abstractmethod + def start() -> None: + pass + + def stop() -> None: + pass \ No newline at end of file diff --git a/src/system_tray/windows_system_tray.py b/src/system_tray/windows_system_tray.py new file mode 100644 index 0000000..b530e3f --- /dev/null +++ b/src/system_tray/windows_system_tray.py @@ -0,0 +1,39 @@ +from .system_tray import SystemTray +from ..notifiers.toast_notifier import ToastNotifier +from infi.systray import SysTrayIcon +import win32con +import win32gui +import ctypes + +class WindowsSystemTray(SystemTray): + def start(self) -> None: + menu_options = (("Hide/Show Console", None, self._hide_window), ("Force Update", None, self._update)) + self.systray = SysTrayIcon("./icon.ico", "YT Music RPC", menu_options, on_quit=self.on_quit_callback) + self.systray.start() + + def stop(self) -> None: + #TODO: ? No idea + pass + + def _update(self) -> None: + toast = ToastNotifier() + try: + toast.show_toast( + "Coming soon!", + "This feature isn't currently avaiable yet.", + duration = 5, + icon_path = f"{os.path.join(os.getcwd(), 'icon.ico')}", + threaded = True, + ) + except TypeError: + pass + + def _hide_window(self) -> None: + if self.showen is True: + self.showen = False + window = ctypes.windll.kernel32.GetConsoleWindow() + win32gui.ShowWindow(window, win32con.SW_HIDE) + elif self.showen is False: + self.showen = True + window = ctypes.windll.kernel32.GetConsoleWindow() + win32gui.ShowWindow(window, win32con.SW_SHOW) diff --git a/src/utils.py b/src/utils.py index a72a9f2..1e8bb2a 100644 --- a/src/utils.py +++ b/src/utils.py @@ -2,8 +2,6 @@ import re import json -import winreg as wr -import os import subprocess as sp import urllib.request as req from .browsers import BROWSERS @@ -25,38 +23,20 @@ def remote_debugging() -> bool: return False -def find_browser(progid) -> dict: +def find_browser(key: str, value: str) -> dict | None: for browser in BROWSERS: - if re.search(browser["name"], progid, re.IGNORECASE): + if re.search(browser[key], value, re.IGNORECASE): + return browser + return None + + +def find_browser_by_process(os_string: str, target_process: str) -> dict | None: + for browser in BROWSERS: + if re.search(browser["process"][os_string], target_process, re.IGNORECASE): return browser return None -def find_windows_process(process_name: str, ref: str) -> bool: - res = sp.check_output( - "WMIC PROCESS WHERE \"name='{process_name}'\" GET ExecutablePath", - stderr=sp.PIPE, - ).decode() - return bool(re.search(ref, res)) - - -def get_default_browser() -> dict: - progid = wr.QueryValueEx( - wr.OpenKey( - wr.HKEY_CURRENT_USER, - r"Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", - ), - "ProgId", - )[0] - if not progid: - raise Exception("Can't find default browser") - browser = find_browser(progid.split(".")[0]) - if not browser: - raise Exception("Unsupported browser, sorry") - browser["path"] = wr.QueryValueEx( - wr.OpenKey(wr.HKEY_CLASSES_ROOT, progid + "\shell\open\command"), "" - )[0].split('"')[1] - return browser def get_browser_tabs(filter_url: str = "") -> list: @@ -66,17 +46,17 @@ def get_browser_tabs(filter_url: str = "") -> list: return tabs -def run_browser(browser: dict, profileDirec) -> None: - # profileDirec ="Profile 1" - profilePath = f'{os.environ["SYSTEMDRIVE"]}\\Users\\{os.getenv("USERNAME") + browser["profilePath"] + profileDirec}' +def run_browser(browser_executable_path: str, + profile_directory:str, + user_directory:str + ) -> None: sp.Popen( [ - browser["path"], - "--profile-directory=" + profileDirec, - "--user-data-dir" + profilePath, - "--app-id=cinhimbnkkaeohfgghhklpknlkffjgod", + browser_executable_path, + "--profile-directory=" + profile_directory, + "--user-data-dir" + user_directory, "--remote-debugging-port=9222", - "--remote-allow-origins=http://127.0.0.1:9222", + "--remote-allow-origins=http://127.0.0.1:9222" ] )