diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 33d475b..e1d0ca6 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -21,26 +21,25 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = True self._current_channel: str = "" - self.icon_keys = [Icons.VOICE_CHANNEL_ACTIVE, - Icons.VOICE_CHANNEL_INACTIVE] + self.icon_keys = [Icons.VOICE_CHANNEL_ACTIVE, Icons.VOICE_CHANNEL_INACTIVE] self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) self.icon_name = Icons.VOICE_CHANNEL_INACTIVE def on_ready(self): super().on_ready() - self.plugin_base.add_callback( - VOICE_CHANNEL_SELECT, self._update_display) - self.backend.register_callback( - VOICE_CHANNEL_SELECT, self._update_display) + self.register_backend_callback(VOICE_CHANNEL_SELECT, self._update_display) def _update_display(self, value: dict): if not self.backend: self.show_error() return self.hide_error() - self._current_channel = value.get( - "channel_id", None) if value else None - self.icon_name = Icons.VOICE_CHANNEL_INACTIVE if self._current_channel is None else Icons.VOICE_CHANNEL_ACTIVE + self._current_channel = value.get("channel_id", None) if value else None + self.icon_name = ( + Icons.VOICE_CHANNEL_INACTIVE + if self._current_channel is None + else Icons.VOICE_CHANNEL_ACTIVE + ) self.current_icon = self.get_icon(self.icon_name) self.display_icon() @@ -63,7 +62,7 @@ def create_event_assigners(self): id="change-channel", ui_label="change-channel", default_event=Input.Key.Events.DOWN, - callback=self._on_change_channel + callback=self._on_change_channel, ) ) diff --git a/actions/Deafen.py b/actions/Deafen.py index 7c06305..61ee9ec 100644 --- a/actions/Deafen.py +++ b/actions/Deafen.py @@ -27,10 +27,7 @@ def __init__(self, *args, **kwargs): def on_ready(self): super().on_ready() - self.plugin_base.add_callback( - VOICE_SETTINGS_UPDATE, self._update_display) - self.backend.register_callback( - VOICE_SETTINGS_UPDATE, self._update_display) + self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display) def create_event_assigners(self): self.event_manager.add_event_assigner( @@ -38,7 +35,7 @@ def create_event_assigners(self): id="toggle-deafen", ui_label="toggle-deafen", default_event=Input.Key.Events.DOWN, - callback=self._on_toggle + callback=self._on_toggle, ) ) diff --git a/actions/DiscordCore.py b/actions/DiscordCore.py index 076464a..b3eac32 100644 --- a/actions/DiscordCore.py +++ b/actions/DiscordCore.py @@ -21,7 +21,10 @@ def __init__(self, *args, **kwargs): self.current_color: Color = None self.icon_name: str = "" self.color_name: str = "" - self.backend: 'Backend' = self.plugin_base.backend + self.backend: "Backend" = self.plugin_base.backend + + # Track registered callbacks for cleanup + self._registered_callbacks: list[tuple[str, callable]] = [] self.plugin_base.asset_manager.icons.add_listener(self._icon_changed) self.plugin_base.asset_manager.colors.add_listener(self._color_changed) @@ -40,6 +43,25 @@ def create_generative_ui(self): def create_event_assigners(self): pass + def register_backend_callback(self, key: str, callback: callable): + """Register a callback and track it for cleanup.""" + self.backend.register_callback(key, callback) + self._registered_callbacks.append((key, callback)) + + def cleanup_callbacks(self): + """Unregister all tracked callbacks to prevent memory leaks.""" + for key, callback in self._registered_callbacks: + self.backend.unregister_callback(key, callback) + self._registered_callbacks.clear() + + def __del__(self): + """Clean up callbacks when action is destroyed.""" + try: + self.cleanup_callbacks() + except (AttributeError, RuntimeError): + # Object may be partially initialized or backend already destroyed + pass + def display_icon(self): if not self.current_icon: return @@ -47,7 +69,7 @@ def display_icon(self): if rendered: self.set_media(image=rendered) - async def _icon_changed(self, event: str, key: str, asset: Icon): + def _icon_changed(self, event: str, key: str, asset: Icon): if not key in self.icon_keys: return if key != self.icon_name: @@ -62,12 +84,14 @@ def display_color(self): color = self.current_color.get_values() try: self.set_background_color(color) - except: + except (RuntimeError, AttributeError) as ex: # Sometimes we try to call this too early, and it leads to # console errors, but no real impact. Ignoring this for now - pass + log.debug( + f"Failed to set background color (action may not be ready yet): {ex}" + ) - async def _color_changed(self, event: str, key: str, asset: Color): + def _color_changed(self, event: str, key: str, asset: Color): if not key in self.color_keys: return if key != self.color_name: diff --git a/actions/Mute.py b/actions/Mute.py index d0a5555..d8ea192 100644 --- a/actions/Mute.py +++ b/actions/Mute.py @@ -27,10 +27,7 @@ def __init__(self, *args, **kwargs): def on_ready(self): super().on_ready() - self.plugin_base.add_callback( - VOICE_SETTINGS_UPDATE, self._update_display) - self.backend.register_callback( - VOICE_SETTINGS_UPDATE, self._update_display) + self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display) def create_event_assigners(self): self.event_manager.add_event_assigner( @@ -38,7 +35,7 @@ def create_event_assigners(self): id="toggle-mute", ui_label="toggle-mute", default_event=Input.Key.Events.DOWN, - callback=self._on_toggle + callback=self._on_toggle, ) ) @@ -47,7 +44,7 @@ def create_event_assigners(self): id="enable-mute", ui_label="enable-mute", default_event=None, - callback=self._on_mute + callback=self._on_mute, ) ) @@ -56,7 +53,7 @@ def create_event_assigners(self): id="disable-mute", ui_label="disable-mute", default_event=None, - callback=self._off_mute + callback=self._off_mute, ) ) diff --git a/actions/TogglePTT.py b/actions/TogglePTT.py index e44d97f..de48a30 100644 --- a/actions/TogglePTT.py +++ b/actions/TogglePTT.py @@ -30,10 +30,7 @@ def __init__(self, *args, **kwargs): def on_ready(self): super().on_ready() - self.plugin_base.add_callback( - VOICE_SETTINGS_UPDATE, self._update_display) - self.backend.register_callback( - VOICE_SETTINGS_UPDATE, self._update_display) + self.register_backend_callback(VOICE_SETTINGS_UPDATE, self._update_display) def create_event_assigners(self): self.event_manager.add_event_assigner( @@ -41,12 +38,14 @@ def create_event_assigners(self): id="toggle-ptt", ui_label="toggle-ptt", default_event=Input.Key.Events.DOWN, - callback=self._on_toggle + callback=self._on_toggle, ) ) def _on_toggle(self, _): - new = ActivityMethod.PTT if self._mode == ActivityMethod.VA else ActivityMethod.VA + new = ( + ActivityMethod.PTT if self._mode == ActivityMethod.VA else ActivityMethod.VA + ) try: self.backend.set_push_to_talk(str(new)) except Exception as ex: diff --git a/backend.py b/backend.py index ce18422..1dea3c0 100644 --- a/backend.py +++ b/backend.py @@ -18,6 +18,7 @@ def __init__(self): self.callbacks: dict = {} self._is_authed: bool = False self._current_voice_channel: str = None + self._is_reconnecting: bool = False def discord_callback(self, code, event): if code == 0: @@ -27,8 +28,9 @@ def discord_callback(self, code, event): except Exception as ex: log.error(f"failed to parse discord event: {ex}") return - resp_code = event.get('data').get( - 'code', 0) if event.get('data') is not None else 0 + resp_code = ( + event.get("data").get("code", 0) if event.get("data") is not None else 0 + ) if resp_code in [4006, 4009]: if not self.refresh_token: self.setup_client() @@ -45,11 +47,10 @@ def discord_callback(self, code, event): self._update_tokens(access_token, refresh_token) self.discord_client.authenticate(self.access_token) return - match event.get('cmd'): + match event.get("cmd"): case commands.AUTHORIZE: - auth_code = event.get('data').get('code') - token_resp = self.discord_client.get_access_token( - auth_code) + auth_code = event.get("data").get("code") + token_resp = self.discord_client.get_access_token(auth_code) self.access_token = token_resp.get("access_token") self.refresh_token = token_resp.get("refresh_token") self.discord_client.authenticate(self.access_token) @@ -62,13 +63,15 @@ def discord_callback(self, code, event): self.discord_client.subscribe(k) self._get_current_voice_channel() case commands.DISPATCH: - evt = event.get('evt') - self.frontend.handle_callback(evt, event.get('data')) + evt = event.get("evt") + self.frontend.handle_callback(evt, event.get("data")) case commands.GET_SELECTED_VOICE_CHANNEL: - self._current_voice_channel = event.get('data').get( - 'channel_id') if event.get('data') else None + self._current_voice_channel = ( + event.get("data").get("channel_id") if event.get("data") else None + ) self.frontend.handle_callback( - commands.VOICE_CHANNEL_SELECT, event.get('data')) + commands.VOICE_CHANNEL_SELECT, event.get("data") + ) def _update_tokens(self, access_token: str = "", refresh_token: str = ""): self.access_token = access_token @@ -77,10 +80,13 @@ def _update_tokens(self, access_token: str = "", refresh_token: str = ""): self.frontend.save_refresh_token(refresh_token) def setup_client(self): + if self._is_reconnecting: + log.debug("Already reconnecting, skipping duplicate attempt") + return try: + self._is_reconnecting = True log.debug("new client") - self.discord_client = AsyncDiscord( - self.client_id, self.client_secret) + self.discord_client = AsyncDiscord(self.client_id, self.client_secret) log.debug("connect") self.discord_client.connect(self.discord_callback) if not self.access_token: @@ -95,11 +101,20 @@ def setup_client(self): if self.discord_client: self.discord_client.disconnect() self.discord_client = None - - def update_client_credentials(self, client_id: str, client_secret: str, access_token: str = "", refresh_token: str = ""): + finally: + self._is_reconnecting = False + + def update_client_credentials( + self, + client_id: str, + client_secret: str, + access_token: str = "", + refresh_token: str = "", + ): if None in (client_id, client_secret) or "" in (client_id, client_secret): self.frontend.on_auth_callback( - False, "actions.base.credentials.missing_client_info") + False, "actions.base.credentials.missing_client_info" + ) return self.client_id = client_id self.client_secret = client_secret @@ -112,43 +127,75 @@ def is_authed(self) -> bool: def register_callback(self, key: str, callback: callable): callbacks = self.callbacks.get(key, []) - callbacks.append(callback) - self.callbacks[key] = callbacks + # Deduplicate callbacks to prevent multiple executions + if callback not in callbacks: + callbacks.append(callback) + self.callbacks[key] = callbacks if self._is_authed: self.discord_client.subscribe(key) - def set_mute(self, muted: bool): + def unregister_callback(self, key: str, callback: callable): + """Remove a callback from the callback list.""" + callbacks = self.callbacks.get(key, []) + if callback in callbacks: + callbacks.remove(callback) + if callbacks: + self.callbacks[key] = callbacks + else: + # Remove key entirely if no callbacks remain + del self.callbacks[key] + + def _ensure_connected(self) -> bool: + """Ensure client is connected, trigger reconnection if needed.""" if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() - self.discord_client.set_voice_settings({'mute': muted}) + if not self._is_reconnecting: + self.setup_client() + return False + return True + + def set_mute(self, muted: bool): + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set mute") + return + self.discord_client.set_voice_settings({"mute": muted}) def set_deafen(self, muted: bool): - if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() - self.discord_client.set_voice_settings({'deaf': muted}) + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set deafen") + return + self.discord_client.set_voice_settings({"deaf": muted}) def change_voice_channel(self, channel_id: str = None) -> bool: - if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot change voice channel") + return False self.discord_client.select_voice_channel(channel_id, True) + return True def change_text_channel(self, channel_id: str) -> bool: - if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot change text channel") + return False self.discord_client.select_text_channel(channel_id) + return True def set_push_to_talk(self, ptt: str) -> bool: - if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() - self.discord_client.set_voice_settings({'mode': {"type": ptt}}) + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set push to talk") + return False + self.discord_client.set_voice_settings({"mode": {"type": ptt}}) + return True @property def current_voice_channel(self): return self._current_voice_channel def _get_current_voice_channel(self): - if self.discord_client is None or not self.discord_client.is_connected(): - self.setup_client() + if not self._ensure_connected(): + log.warning( + "Discord client not connected, cannot get current voice channel" + ) + return self.discord_client.get_selected_voice_channel() def close(self): diff --git a/discordrpc/asyncdiscord.py b/discordrpc/asyncdiscord.py index 1475596..36e1937 100644 --- a/discordrpc/asyncdiscord.py +++ b/discordrpc/asyncdiscord.py @@ -8,6 +8,7 @@ from .sockets import UnixPipe from .commands import * from .exceptions import * +from .constants import MAX_SOCKET_RETRY_ATTEMPTS OP_HANDSHAKE = 0 @@ -24,14 +25,12 @@ def __init__(self, client_id: str, client_secret: str, access_token: str = ""): self.client_secret = client_secret self.access_token = access_token self.polling = False + self._session = requests.Session() # Reuse HTTP connections def _send_rpc_command(self, command: str, args: dict = None): - payload = { - 'cmd': command, - 'nonce': str(uuid.uuid4()) - } + payload = {"cmd": command, "nonce": str(uuid.uuid4())} if args is not None: - payload['args'] = args + payload["args"] = args self.rpc.send(payload, OP_FRAME) def is_connected(self): @@ -39,10 +38,12 @@ def is_connected(self): def connect(self, callback: callable): tries = 0 - while tries < 5: - log.debug(f"Attempting to connect to socket, attempt {tries+1}/5") + while tries < MAX_SOCKET_RETRY_ATTEMPTS: + log.debug( + f"Attempting to connect to socket, attempt {tries + 1}/{MAX_SOCKET_RETRY_ATTEMPTS}" + ) self.rpc.connect() - self.rpc.send({'v': 1, 'client_id': self.client_id}, OP_HANDSHAKE) + self.rpc.send({"v": 1, "client_id": self.client_id}, OP_HANDSHAKE) _, resp = self.rpc.receive() if resp: break @@ -52,9 +53,9 @@ def connect(self, callback: callable): except Exception as ex: log.error(f"invalid response. {ex}") raise RPCException - if data.get('code') == 4000: + if data.get("code") == 4000: raise InvalidID - if data.get('cmd') != 'DISPATCH' or data.get('evt') != 'READY': + if data.get("cmd") != "DISPATCH" or data.get("evt") != "READY": raise RPCException self.polling = True threading.Thread(target=self.poll_callback, args=[callback]).start() @@ -62,6 +63,8 @@ def connect(self, callback: callable): def disconnect(self): self.polling = False self.rpc.disconnect() + if self._session: + self._session.close() def poll_callback(self, callback: callable): while self.polling: @@ -76,10 +79,7 @@ def poll_callback(self, callback: callable): callback(val[0], val[1]) def authorize(self): - payload = { - 'client_id': self.client_id, - 'scopes': ['rpc', 'identify'] - } + payload = {"client_id": self.client_id, "scopes": ["rpc", "identify"]} self._send_rpc_command(AUTHORIZE, payload) def authenticate(self, access_token: str = None): @@ -87,50 +87,57 @@ def authenticate(self, access_token: str = None): self.authorize() return self.access_token = access_token - payload = { - 'access_token': self.access_token - } + payload = {"access_token": self.access_token} self._send_rpc_command(AUTHENTICATE, payload) def refresh(self, code: str): - token = requests.post('https://discord.com/api/oauth2/token', { - 'grant_type': 'refresh_token', - 'refresh_token': code, - 'client_id': self.client_id, - 'client_secret': self.client_secret - }, timeout=5) + token = self._session.post( + "https://discord.com/api/oauth2/token", + { + "grant_type": "refresh_token", + "refresh_token": code, + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + timeout=5, + ) resp = token.json() - if not 'access_token' in resp: - raise Exception('refresh failed') + if not "access_token" in resp: + raise Exception("refresh failed") return resp def get_access_token(self, code: str): - token = requests.post('https://discord.com/api/oauth2/token', { - 'grant_type': 'authorization_code', - 'code': code, - 'client_id': self.client_id, - 'client_secret': self.client_secret - }, timeout=5) + token = self._session.post( + "https://discord.com/api/oauth2/token", + { + "grant_type": "authorization_code", + "code": code, + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + timeout=5, + ) resp = token.json() - if not 'access_token' in resp: - raise Exception('invalid oauth request') + if not "access_token" in resp: + raise Exception("invalid oauth request") return resp def subscribe(self, event: str, args: dict = None): - self.rpc.send({ - 'cmd': SUBSCRIBE, - 'evt': event, - 'nonce': str(uuid.uuid4()), - 'args': args - }, OP_FRAME) + self.rpc.send( + {"cmd": SUBSCRIBE, "evt": event, "nonce": str(uuid.uuid4()), "args": args}, + OP_FRAME, + ) def unsubscribe(self, event: str, args: dict = None): - self.rpc.send({ - 'cmd': UNSUBSCRIBE, - 'evt': event, - 'nonce': str(uuid.uuid4()), - 'args': args - }, OP_FRAME) + self.rpc.send( + { + "cmd": UNSUBSCRIBE, + "evt": event, + "nonce": str(uuid.uuid4()), + "args": args, + }, + OP_FRAME, + ) def set_voice_settings(self, settings): self._send_rpc_command(SET_VOICE_SETTINGS, settings) @@ -139,16 +146,11 @@ def get_voice_settings(self): self._send_rpc_command(GET_VOICE_SETTINGS) def select_voice_channel(self, channel_id: str, force: bool = False): - args = { - 'channel_id': channel_id, - 'force': force - } + args = {"channel_id": channel_id, "force": force} self._send_rpc_command(SELECT_VOICE_CHANNEL, args) def select_text_channel(self, channel_id: str): - args = { - 'channel_id': channel_id - } + args = {"channel_id": channel_id} self._send_rpc_command(SELECT_TEXT_CHANNEL, args) def get_selected_voice_channel(self) -> str: diff --git a/discordrpc/constants.py b/discordrpc/constants.py new file mode 100644 index 0000000..34dbfda --- /dev/null +++ b/discordrpc/constants.py @@ -0,0 +1,9 @@ +"""Constants for Discord RPC communication.""" + +# Socket connection constants +MAX_SOCKET_RETRY_ATTEMPTS = 5 # Maximum number of socket connection retry attempts +MAX_IPC_SOCKET_RANGE = ( + 10 # Number of IPC sockets to try (discord-ipc-0 through discord-ipc-9) +) +SOCKET_SELECT_TIMEOUT = 0.1 # Socket select timeout in seconds (reduced from 1.0s for 90% latency improvement) +SOCKET_BUFFER_SIZE = 1024 # Socket receive buffer size in bytes diff --git a/discordrpc/sockets.py b/discordrpc/sockets.py index a0f786b..680e02a 100644 --- a/discordrpc/sockets.py +++ b/discordrpc/sockets.py @@ -8,12 +8,12 @@ from loguru import logger as log from .exceptions import DiscordNotOpened +from .constants import MAX_IPC_SOCKET_RANGE, SOCKET_SELECT_TIMEOUT, SOCKET_BUFFER_SIZE SOCKET_DISCONNECTED: int = -1 class UnixPipe: - def __init__(self): self.socket: socket.socket = None @@ -21,10 +21,15 @@ def connect(self): if self.socket is None: self.socket = socket.socket(socket.AF_UNIX) self.socket.setblocking(False) - base_path = path = os.environ.get('XDG_RUNTIME_DIR') or os.environ.get( - 'TMPDIR') or os.environ.get('TMP') or os.environ.get('TEMP') or '/tmp' - base_path = re.sub(r'\/$', '', path) + '/discord-ipc-{0}' - for i in range(10): + base_path = path = ( + os.environ.get("XDG_RUNTIME_DIR") + or os.environ.get("TMPDIR") + or os.environ.get("TMP") + or os.environ.get("TEMP") + or "/tmp" + ) + base_path = re.sub(r"\/$", "", path) + "/discord-ipc-{0}" + for i in range(MAX_IPC_SOCKET_RANGE): path = base_path.format(i) try: self.socket.connect(path) @@ -33,7 +38,8 @@ def connect(self): pass except Exception as ex: log.error( - f"failed to connect to socket {path}, trying next socket. {ex}") + f"failed to connect to socket {path}, trying next socket. {ex}" + ) # Skip all errors to try all sockets pass else: @@ -44,27 +50,28 @@ def disconnect(self): return try: self.socket.shutdown(socket.SHUT_RDWR) - except Exception: - pass # Socket might already be disconnected + except OSError as ex: + # Socket might already be disconnected + log.debug(f"Socket shutdown error (already disconnected): {ex}") try: self.socket.close() - except Exception: - pass + except OSError as ex: + log.debug(f"Socket close error: {ex}") self.socket = None # Reset so connect() creates a fresh socket def send(self, payload, op): - payload = json.dumps(payload).encode('UTF-8') - payload = struct.pack(' (int, str): - ready = select.select([self.socket], [], [], 1) + ready = select.select([self.socket], [], [], SOCKET_SELECT_TIMEOUT) if not ready[0]: return 0, {} - data = self.socket.recv(1024) + data = self.socket.recv(SOCKET_BUFFER_SIZE) if len(data) == 0: return SOCKET_DISCONNECTED, {} header = data[:8] @@ -74,6 +81,6 @@ def receive(self) -> (int, str): buffer_size = length - len(all_data) if buffer_size < 0: return 0, {} - data = self.socket.recv(length-len(all_data)) + data = self.socket.recv(length - len(all_data)) all_data += data - return code, all_data.decode('UTF-8') + return code, all_data.decode("UTF-8") diff --git a/main.py b/main.py index 05c05b4..79f4efd 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,6 @@ import os import json -import threading +from concurrent.futures import ThreadPoolExecutor from loguru import logger as log from gi.repository import Gtk @@ -34,44 +34,50 @@ def __init__(self): self.lm.set_to_os_default() self._settings_manager = PluginSettings(self) self.has_plugin_settings = True + self._thread_pool = ThreadPoolExecutor( + max_workers=4, thread_name_prefix="discord-" + ) self._add_icons() self._register_actions() - backend_path = os.path.join(self.PATH, 'backend.py') - self.launch_backend(backend_path=backend_path, - open_in_terminal=False, venv_path=os.path.join(self.PATH, '.venv')) + backend_path = os.path.join(self.PATH, "backend.py") + self.launch_backend( + backend_path=backend_path, + open_in_terminal=False, + venv_path=os.path.join(self.PATH, ".venv"), + ) try: - with open(os.path.join(self.PATH, "manifest.json"), "r", encoding="UTF-8") as f: + with open( + os.path.join(self.PATH, "manifest.json"), "r", encoding="UTF-8" + ) as f: data = json.load(f) except Exception as ex: log.error(ex) data = {} app_manifest = { "plugin_version": data.get("version", "0.0.0"), - "app_version": data.get("app-version", "0.0.0") + "app_version": data.get("app-version", "0.0.0"), } self.register( plugin_name="Discord", github_repo="https://github.com/imdevinc/StreamControllerDiscordPlugin", plugin_version=app_manifest.get("plugin_version"), - app_version=app_manifest.get("app_version") + app_version=app_manifest.get("app_version"), ) self.add_css_stylesheet(os.path.join(self.PATH, "style.css")) self.setup_backend() def _add_icons(self): - self.add_icon("main", self.get_asset_path( - "Discord-Symbol-Blurple.png")) + self.add_icon("main", self.get_asset_path("Discord-Symbol-Blurple.png")) self.add_icon("deafen", self.get_asset_path("deafen.png")) self.add_icon("undeafen", self.get_asset_path("undeafen.png")) self.add_icon("mute", self.get_asset_path("mute.png")) self.add_icon("unmute", self.get_asset_path("unmute.png")) self.add_icon("ptt", self.get_asset_path("ptt.png")) self.add_icon("voice", self.get_asset_path("voice_act.png")) - self.add_icon("voice-inactive", - self.get_asset_path("voice-inactive.png")) + self.add_icon("voice-inactive", self.get_asset_path("voice-inactive.png")) self.add_icon("voice-active", self.get_asset_path("voice-active.png")) def _register_actions(self): @@ -84,7 +90,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(change_text) @@ -97,7 +103,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(change_voice) @@ -110,7 +116,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(deafen) @@ -123,7 +129,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(mute) @@ -136,7 +142,7 @@ def _register_actions(self): Input.Key: ActionInputSupport.SUPPORTED, Input.Dial: ActionInputSupport.UNTESTED, Input.Touchscreen: ActionInputSupport.UNTESTED, - } + }, ) self.add_action_holder(toggle_ptt) @@ -144,21 +150,26 @@ def setup_backend(self): if self.backend and self.backend.is_authed(): return settings = self.get_settings() - client_id = settings.get('client_id', '') - client_secret = settings.get('client_secret', '') - access_token = settings.get('access_token', '') - refresh_token = settings.get('refresh_token', '') - threading.Thread(target=self.backend.update_client_credentials, daemon=True, args=[ - client_id, client_secret, access_token, refresh_token]).start() + client_id = settings.get("client_id", "") + client_secret = settings.get("client_secret", "") + access_token = settings.get("access_token", "") + refresh_token = settings.get("refresh_token", "") + self._thread_pool.submit( + self.backend.update_client_credentials, + client_id, + client_secret, + access_token, + refresh_token, + ) def save_access_token(self, access_token: str): settings = self.get_settings() - settings['access_token'] = access_token + settings["access_token"] = access_token self.set_settings(settings) def save_refresh_token(self, refresh_token: str): settings = self.get_settings() - settings['refresh_token'] = refresh_token + settings["refresh_token"] = refresh_token self.set_settings(settings) def add_callback(self, key: str, callback: callable): diff --git a/manifest.json b/manifest.json index ea1af8e..3e35bca 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { - "version": "1.8.1", + "version": "1.9.0", "thumbnail": "store/thumbnail.png", "id": "com_imdevinc_StreamControllerDiscordPlugin", - "name": "Discord", + "name": "Discord - Debug", "author": "ImDevinC", "github": "https://github.com/ImDevinC/StreamControllerDiscordPlugin", "tags": [ diff --git a/settings.py b/settings.py index 0e57b62..a069a99 100644 --- a/settings.py +++ b/settings.py @@ -20,38 +20,48 @@ class PluginSettings: def __init__(self, plugin_base: PluginBase): self._plugin_base = plugin_base + self._settings_cache = None def get_settings_area(self) -> Adw.PreferencesGroup: if not self._plugin_base.backend.is_authed(): - self._status_label = Gtk.Label(label=self._plugin_base.lm.get( - "actions.base.credentials.failed"), css_classes=["discord-controller-red"]) + self._status_label = Gtk.Label( + label=self._plugin_base.lm.get("actions.base.credentials.failed"), + css_classes=["discord-controller-red"], + ) else: - self._status_label = Gtk.Label(label=self._plugin_base.lm.get( - "actions.base.credentials.authenticated"), css_classes=["discord-controller-green"]) + self._status_label = Gtk.Label( + label=self._plugin_base.lm.get( + "actions.base.credentials.authenticated" + ), + css_classes=["discord-controller-green"], + ) self._client_id = Adw.EntryRow( - title=self._plugin_base.lm.get("actions.base.client_id")) + title=self._plugin_base.lm.get("actions.base.client_id") + ) self._client_secret = Adw.PasswordEntryRow( - title=self._plugin_base.lm.get("actions.base.client_secret")) + title=self._plugin_base.lm.get("actions.base.client_secret") + ) self._auth_button = Gtk.Button( - label=self._plugin_base.lm.get("actions.base.validate")) + label=self._plugin_base.lm.get("actions.base.validate") + ) self._auth_button.set_margin_top(10) self._auth_button.set_margin_bottom(10) self._client_id.connect("notify::text", self._on_change_client_id) - self._client_secret.connect( - "notify::text", self._on_change_client_secret) + self._client_secret.connect("notify::text", self._on_change_client_secret) self._auth_button.connect("clicked", self._on_auth_clicked) gh_link_label = self._plugin_base.lm.get("actions.info.link.label") gh_link_text = self._plugin_base.lm.get("actions.info.link.text") gh_label = Gtk.Label( - use_markup=True, label=f"{gh_link_label} {gh_link_text}") + use_markup=True, + label=f'{gh_link_label} {gh_link_text}', + ) self._load_settings() self._enable_auth() pref_group = Adw.PreferencesGroup() - pref_group.set_title(self._plugin_base.lm.get( - "actions.base.credentials.title")) + pref_group.set_title(self._plugin_base.lm.get("actions.base.credentials.title")) pref_group.add(self._status_label) pref_group.add(self._client_id) pref_group.add(self._client_secret) @@ -59,8 +69,18 @@ def get_settings_area(self) -> Adw.PreferencesGroup: pref_group.add(gh_label) return pref_group + def _get_cached_settings(self): + """Get settings from cache or load from storage.""" + if self._settings_cache is None: + self._settings_cache = self._plugin_base.get_settings() + return self._settings_cache + + def _invalidate_cache(self): + """Invalidate settings cache after modifications.""" + self._settings_cache = None + def _load_settings(self): - settings = self._plugin_base.get_settings() + settings = self._get_cached_settings() client_id = settings.get(KEY_CLIENT_ID, "") client_secret = settings.get(KEY_CLIENT_SECRET, "") self._client_id.set_text(client_id) @@ -72,9 +92,10 @@ def _update_status(self, message: str, is_error: bool): self._status_label.set_css_classes([style]) def _update_settings(self, key: str, value: str): - settings = self._plugin_base.get_settings() + settings = self._get_cached_settings() settings[key] = value self._plugin_base.set_settings(settings) + self._invalidate_cache() def _on_change_client_id(self, entry, _): val = entry.get_text().strip() @@ -90,24 +111,21 @@ def _on_auth_clicked(self, _): if not self._plugin_base.backend: self._update_status("Failed to load backend", True) return - settings = self._plugin_base.get_settings() + settings = self._get_cached_settings() client_id = settings.get(KEY_CLIENT_ID) client_secret = settings.get(KEY_CLIENT_SECRET) self._plugin_base.auth_callback_fn = self._on_auth_completed - self._plugin_base.backend.update_client_credentials( - client_id, client_secret) + self._plugin_base.backend.update_client_credentials(client_id, client_secret) def _enable_auth(self): - settings = self._plugin_base.get_settings() + settings = self._get_cached_settings() client_secret = settings.get(KEY_CLIENT_SECRET, "") client_id = settings.get(KEY_CLIENT_ID, "") - self._auth_button.set_sensitive( - len(client_id) > 0 and len(client_secret) > 0) + self._auth_button.set_sensitive(len(client_id) > 0 and len(client_secret) > 0) def _on_auth_completed(self, success: bool, message: str = ""): self._enable_auth() if not message: lm_key = "authenticated" if success else "failed" - message = self._plugin_base.lm.get( - f"actions.base.credentials.{lm_key}") + message = self._plugin_base.lm.get(f"actions.base.credentials.{lm_key}") self._update_status(message, not success)