From 20ebddfcff1c9652abcf510044c8379808d90a4e Mon Sep 17 00:00:00 2001 From: cwilvx Date: Tue, 27 May 2025 12:32:05 +0300 Subject: [PATCH] new: add system tray icon --- pyproject.toml | 1 + swingmusic/__main__.py | 276 ++++++++------------------------- swingmusic/start_swingmusic.py | 208 +++++++++++++++++++++++++ uv.lock | 62 ++++++++ 4 files changed, 336 insertions(+), 211 deletions(-) create mode 100644 swingmusic/start_swingmusic.py diff --git a/pyproject.toml b/pyproject.toml index 632c7b09..470957ff 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "rapidfuzz==3.11.0", "pendulum>=3.0.0", "bjoern>=3.2.2", + "pystray>=0.19.5", ] [dependency-groups] diff --git a/swingmusic/__main__.py b/swingmusic/__main__.py index 3e55f188..fd0d45ef 100644 --- a/swingmusic/__main__.py +++ b/swingmusic/__main__.py @@ -2,34 +2,67 @@ import os import sys import click import pathlib -import logging -import mimetypes -import setproctitle +import pystray import multiprocessing +from PIL import Image +from typing import Callable +from pystray._base import Icon as PystrayIcon - -from flask_jwt_extended import ( - create_access_token, - get_jwt, - get_jwt_identity, - set_access_cookies, - verify_jwt_in_request, -) -from datetime import datetime, timezone -from flask import Response, request - -from swingmusic import settings -from swingmusic.api import create_api -from swingmusic.crons import start_cron_jobs -from swingmusic.utils.threading import background -from swingmusic.setup import load_into_mem, run_setup -from swingmusic.plugins.register import register_plugins +from swingmusic.start_swingmusic import start_swingmusic from swingmusic.utils.xdg_utils import get_xdg_config_dir -from swingmusic.start_info_logger import log_startup_info from swingmusic.utils.filesystem import get_home_res_path -from swingmusic.utils.paths import getClientFilesExtensions from swingmusic.arg_handler import handle_build, handle_password_reset + +class App: + def __init__(self, host: str, port: int, setup: Callable[[], None]): + self.host: str = host + self.port: int = port + self.icon: PystrayIcon = None + self.setup = setup + self.process = multiprocessing.Process( + target=self.setup, args=(self.host, self.port) + ) + + def start(self, icon: PystrayIcon): + self.icon = icon + self.icon.visible = True + self.process.start() + self.icon.run() + + def stop(self): + print("\nShutting down ...", end=" ") + self.process.terminate() + self.process.join(timeout=1) + self.icon.stop() + print("bye! 👋") + + +def create_image(width, height, color1, color2): + # Generate an image and draw a pattern + padding = 7 + icon_path = get_home_res_path("assets/logo-fill.light.ico") + image = Image.open(icon_path) + + # Calculate new size with padding + new_size = (width - 2 * padding, height - 2 * padding) + + # Resize the image while maintaining aspect ratio + image.thumbnail(new_size, Image.Resampling.LANCZOS) + + # Create a new image with padding + padded_image = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + + # Calculate position to center the image + x = (width - image.width) // 2 + y = (height - image.height) // 2 + + # Paste the resized image onto the padded image + padded_image.paste(image, (x, y), image) + + return padded_image + + def print_version(*args, **kwargs): """ Prints the version of the application. @@ -49,194 +82,6 @@ def print_version(*args, **kwargs): sys.exit(0) -def start_swingmusic(host: str, port: int): - """ - Creates and starts the Flask application server for Swing Music. - - This function sets up the Flask application with all necessary - configurations, including static file handling, authentication middleware, and - server setup, then runs it. It also sets up background tasks and cron jobs. - - Args: - host (str): The host address to bind the server to (e.g., 'localhost' or '0.0.0.0') - port (int): The port number to run the server on - - Note: - The application uses either bjoern or waitress as the WSGI server, - depending on availability. It also includes JWT authentication, - static file serving with gzip compression support, and automatic - token refresh functionality. - """ - - # Example: Setting up dirs, database, and loading stuff into memory. - # TIP: Be careful with the order of the setup functions. - - # Load mimetypes for the web client's static files - # Loading mimetypes should happen automatically but - # sometimes the mimetypes are not loaded correctly - # eg. when the Registry is messed up on Windows. - - # See the following issues: - # https://github.com/swingmx/swingmusic/issues/137 - - mimetypes.add_type("text/css", ".css") - mimetypes.add_type("text/javascript", ".js") - mimetypes.add_type("text/plain", ".txt") - mimetypes.add_type("text/html", ".html") - mimetypes.add_type("image/webp", ".webp") - mimetypes.add_type("image/svg+xml", ".svg") - mimetypes.add_type("image/png", ".png") - mimetypes.add_type("image/vnd.microsoft.icon", ".ico") - mimetypes.add_type("image/gif", ".gif") - mimetypes.add_type("font/woff", ".woff") - mimetypes.add_type("application/manifest+json", ".webmanifest") - - # logging.disable(logging.CRITICAL) - # werkzeug = logging.getLogger("werkzeug") - # werkzeug.setLevel(logging.ERROR) - - waitress_logger = logging.getLogger("waitress") - waitress_logger.setLevel(logging.ERROR) - - log_startup_info(host, port) - - @background - def run_swingmusic(): - register_plugins() - - setproctitle.setproctitle(f"swingmusic {host}:{port}") - start_cron_jobs() - - # Setup function calls - settings.Info.load() - run_setup() - - # Create the Flask app - app = create_api() - app.static_folder = get_home_res_path("client") - - # INFO: Routes that don't need authentication - whitelisted_routes = { - "/auth/login", - "/auth/users", - "/auth/pair", - "/auth/logout", - "/auth/refresh", - "/docs", - } - blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions()) - - def skipAuthAction(): - """ - Skips the JWT verification for the current request. - """ - if request.path == "/" or any( - request.path.endswith(ext) for ext in blacklist_extensions - ): - return True - - # if request path starts with any of the blacklisted routes, don't verify jwt - if any(request.path.startswith(route) for route in whitelisted_routes): - return True - - return False - - @app.before_request - def verify_auth(): - """ - Verifies the JWT token before each request. - """ - if skipAuthAction(): - return - - verify_jwt_in_request() - - @app.after_request - def refresh_expiring_jwt(response: Response): - """ - Refreshes the cookies JWT token after each request. - """ - - # INFO: If the request has an Authorization header, don't refresh the jwt - # Request is probably from the mobile client or a third party - if skipAuthAction() or request.headers.get("Authorization"): - return response - - try: - exp_timestamp = get_jwt()["exp"] - now = datetime.now(timezone.utc) - target_timestamp = datetime.timestamp(now) + 60 * 60 * 24 * 7 # 7 days - - if target_timestamp > exp_timestamp: - access_token = create_access_token(identity=get_jwt_identity()) - set_access_cookies(response, access_token) - - return response - except (RuntimeError, KeyError): - return response - - @app.route("/") - def serve_client_files(path: str): - """ - Serves the static files in the client folder. - """ - js_or_css = path.endswith(".js") or path.endswith(".css") - if not js_or_css: - return app.send_static_file(path) - - gzipped_path = path + ".gz" - user_agent = request.headers.get("User-Agent") - - # INFO: Safari doesn't support gzip encoding - # See issue: https://github.com/swingmx/swingmusic/issues/155 - is_safari = ( - user_agent - and user_agent.find("Safari") >= 0 - and user_agent.find("Chrome") < 0 - ) - - if is_safari: - return app.send_static_file(path) - - accepts_gzip = request.headers.get("Accept-Encoding", "").find("gzip") >= 0 - - if accepts_gzip: - if os.path.exists(os.path.join(app.static_folder or "", gzipped_path)): - response = app.make_response(app.send_static_file(gzipped_path)) - response.headers["Content-Encoding"] = "gzip" - return response - - return app.send_static_file(path) - - @app.route("/") - def serve_client(): - """ - Serves the index.html file at `client/index.html`. - """ - return app.send_static_file("index.html") - - load_into_mem() - run_swingmusic() - # TrackStore.export() - # ArtistStore.export() - - try: - import bjoern - - bjoern.run(app, host, port) - except ImportError: - import waitress - - waitress.serve( - app, - host=host, - port=port, - threads=100, - ipv6=True, - ipv4=True, - ) - - @click.command(options_metavar="", context_settings={"show_default": True}) @click.option( "--build", @@ -287,7 +132,16 @@ def run(*args, **kwargs): os.environ["SWINGMUSIC_XDG_CONFIG_DIR"] = str( pathlib.Path(kwargs["config"]).resolve() ) - start_swingmusic(kwargs["host"], kwargs["port"]) + + app = App(kwargs["host"], kwargs["port"], start_swingmusic) + icon = pystray.Icon( + "Swing Music", + icon=create_image(64, 64, "black", "white"), + menu=pystray.Menu( + pystray.MenuItem("Quit Swing Music", app.stop), + ), + ) + app.start(icon) if __name__ == "__main__": diff --git a/swingmusic/start_swingmusic.py b/swingmusic/start_swingmusic.py new file mode 100644 index 00000000..aad869bf --- /dev/null +++ b/swingmusic/start_swingmusic.py @@ -0,0 +1,208 @@ +from swingmusic import settings +from swingmusic.api import create_api +from swingmusic.crons import start_cron_jobs +from swingmusic.plugins.register import register_plugins +from swingmusic.setup import load_into_mem, run_setup +from swingmusic.start_info_logger import log_startup_info +from swingmusic.utils.filesystem import get_home_res_path +from swingmusic.utils.paths import getClientFilesExtensions +from swingmusic.utils.threading import background + + +import setproctitle +from flask import Response, request +from flask_jwt_extended import create_access_token, get_jwt, get_jwt_identity, set_access_cookies, verify_jwt_in_request + + +import logging +import mimetypes +import os +from datetime import datetime, timezone + + +def start_swingmusic(host: str, port: int): + """ + Creates and starts the Flask application server for Swing Music. + + This function sets up the Flask application with all necessary + configurations, including static file handling, authentication middleware, and + server setup, then runs it. It also sets up background tasks and cron jobs. + + Args: + host (str): The host address to bind the server to (e.g., 'localhost' or '0.0.0.0') + port (int): The port number to run the server on + + Note: + The application uses either bjoern or waitress as the WSGI server, + depending on availability. It also includes JWT authentication, + static file serving with gzip compression support, and automatic + token refresh functionality. + """ + + # Example: Setting up dirs, database, and loading stuff into memory. + # TIP: Be careful with the order of the setup functions. + + # Load mimetypes for the web client's static files + # Loading mimetypes should happen automatically but + # sometimes the mimetypes are not loaded correctly + # eg. when the Registry is messed up on Windows. + + # See the following issues: + # https://github.com/swingmx/swingmusic/issues/137 + + mimetypes.add_type("text/css", ".css") + mimetypes.add_type("text/javascript", ".js") + mimetypes.add_type("text/plain", ".txt") + mimetypes.add_type("text/html", ".html") + mimetypes.add_type("image/webp", ".webp") + mimetypes.add_type("image/svg+xml", ".svg") + mimetypes.add_type("image/png", ".png") + mimetypes.add_type("image/vnd.microsoft.icon", ".ico") + mimetypes.add_type("image/gif", ".gif") + mimetypes.add_type("font/woff", ".woff") + mimetypes.add_type("application/manifest+json", ".webmanifest") + + # logging.disable(logging.CRITICAL) + # werkzeug = logging.getLogger("werkzeug") + # werkzeug.setLevel(logging.ERROR) + + waitress_logger = logging.getLogger("waitress") + waitress_logger.setLevel(logging.ERROR) + + log_startup_info(host, port) + + @background + def run_swingmusic(): + register_plugins() + + setproctitle.setproctitle(f"swingmusic {host}:{port}") + start_cron_jobs() + + # Setup function calls + settings.Info.load() + run_setup() + + # Create the Flask app + app = create_api() + app.static_folder = get_home_res_path("client") + + # INFO: Routes that don't need authentication + whitelisted_routes = { + "/auth/login", + "/auth/users", + "/auth/pair", + "/auth/logout", + "/auth/refresh", + "/docs", + } + blacklist_extensions = {".webp", ".jpg"}.union(getClientFilesExtensions()) + + def skipAuthAction(): + """ + Skips the JWT verification for the current request. + """ + if request.path == "/" or any( + request.path.endswith(ext) for ext in blacklist_extensions + ): + return True + + # if request path starts with any of the blacklisted routes, don't verify jwt + if any(request.path.startswith(route) for route in whitelisted_routes): + return True + + return False + + @app.before_request + def verify_auth(): + """ + Verifies the JWT token before each request. + """ + if skipAuthAction(): + return + + verify_jwt_in_request() + + @app.after_request + def refresh_expiring_jwt(response: Response): + """ + Refreshes the cookies JWT token after each request. + """ + + # INFO: If the request has an Authorization header, don't refresh the jwt + # Request is probably from the mobile client or a third party + if skipAuthAction() or request.headers.get("Authorization"): + return response + + try: + exp_timestamp = get_jwt()["exp"] + now = datetime.now(timezone.utc) + target_timestamp = datetime.timestamp(now) + 60 * 60 * 24 * 7 # 7 days + + if target_timestamp > exp_timestamp: + access_token = create_access_token(identity=get_jwt_identity()) + set_access_cookies(response, access_token) + + return response + except (RuntimeError, KeyError): + return response + + @app.route("/") + def serve_client_files(path: str): + """ + Serves the static files in the client folder. + """ + js_or_css = path.endswith(".js") or path.endswith(".css") + if not js_or_css: + return app.send_static_file(path) + + gzipped_path = path + ".gz" + user_agent = request.headers.get("User-Agent") + + # INFO: Safari doesn't support gzip encoding + # See issue: https://github.com/swingmx/swingmusic/issues/155 + is_safari = ( + user_agent + and user_agent.find("Safari") >= 0 + and user_agent.find("Chrome") < 0 + ) + + if is_safari: + return app.send_static_file(path) + + accepts_gzip = request.headers.get("Accept-Encoding", "").find("gzip") >= 0 + + if accepts_gzip: + if os.path.exists(os.path.join(app.static_folder or "", gzipped_path)): + response = app.make_response(app.send_static_file(gzipped_path)) + response.headers["Content-Encoding"] = "gzip" + return response + + return app.send_static_file(path) + + @app.route("/") + def serve_client(): + """ + Serves the index.html file at `client/index.html`. + """ + return app.send_static_file("index.html") + + load_into_mem() + run_swingmusic() + # TrackStore.export() + # ArtistStore.export() + + try: + import bjoern + + bjoern.run(app, host, port) + except ImportError: + import waitress + + waitress.serve( + app, + host=host, + port=port, + threads=100, + ipv6=True, + ipv4=True, + ) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 61d59473..6374419b 100644 --- a/uv.lock +++ b/uv.lock @@ -685,6 +685,54 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, ] +[[package]] +name = "pyobjc-core" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/94/a111239b98260869780a5767e5d74bfd3a8c13a40457f479c28dcd91f89d/pyobjc_core-11.0.tar.gz", hash = "sha256:63bced211cb8a8fb5c8ff46473603da30e51112861bd02c438fbbbc8578d9a70", size = 994931 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/05/fa97309c3b1bc1ec90d701db89902e0bd5e1024023aa2c5387b889458b1b/pyobjc_core-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:50675c0bb8696fe960a28466f9baf6943df2928a1fd85625d678fa2f428bd0bd", size = 727295 }, +] + +[[package]] +name = "pyobjc-framework-cocoa" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/32/53809096ad5fc3e7a2c5ddea642590a5f2cb5b81d0ad6ea67fdb2263d9f9/pyobjc_framework_cocoa-11.0.tar.gz", hash = "sha256:00346a8cb81ad7b017b32ff7bf596000f9faa905807b1bd234644ebd47f692c5", size = 6173848 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/97/81fd41ad90e9c241172110aa635a6239d56f50d75923aaedbbe351828580/pyobjc_framework_Cocoa-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3ea7be6e6dd801b297440de02d312ba3fa7fd3c322db747ae1cb237e975f5d33", size = 385534 }, +] + +[[package]] +name = "pyobjc-framework-quartz" +version = "11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/ad/f00f3f53387c23bbf4e0bb1410e11978cbf87c82fa6baff0ee86f74c5fb6/pyobjc_framework_quartz-11.0.tar.gz", hash = "sha256:3205bf7795fb9ae34747f701486b3db6dfac71924894d1f372977c4d70c3c619", size = 3952463 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/6a/68957c8c5e8f0128d4d419728bac397d48fa7ad7a66e82b70e64d129ffca/pyobjc_framework_Quartz-11.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d251696bfd8e8ef72fbc90eb29fec95cb9d1cc409008a183d5cc3246130ae8c2", size = 212349 }, +] + +[[package]] +name = "pystray" +version = "0.19.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "sys_platform == 'linux'" }, + { name = "six" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068 }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -728,6 +776,18 @@ client = [ { name = "websocket-client" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185 }, +] + [[package]] name = "pywin32" version = "308" @@ -942,6 +1002,7 @@ dependencies = [ { name = "pendulum" }, { name = "pillow" }, { name = "psutil" }, + { name = "pystray" }, { name = "rapidfuzz" }, { name = "requests" }, { name = "schedule" }, @@ -977,6 +1038,7 @@ requires-dist = [ { name = "pendulum", specifier = ">=3.0.0" }, { name = "pillow", specifier = ">=11.1.0" }, { name = "psutil", specifier = ">=5.9.4" }, + { name = "pystray", specifier = ">=0.19.5" }, { name = "rapidfuzz", specifier = "==3.11.0" }, { name = "requests", specifier = ">=2.27.1" }, { name = "schedule", specifier = ">=1.2.2" },