From 4d15799b5eb372297d11eddc85914aff002374d7 Mon Sep 17 00:00:00 2001 From: filip Date: Fri, 12 Jun 2026 16:08:31 +0200 Subject: [PATCH] GUI/CLI fixes: crash paths, privacy-lock bypass, threading, logout leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - _on_image_download_failed called self.statusBar() which does not exist on a QWidget -> AttributeError crash; use status_bar label. - Ctrl+Shift+P no longer disables the privacy overlay while the session is password-locked (lock bypass). - Registration code confirmation no longer touches Qt widgets from the asyncio thread; new AsyncBridge.confirm_result signal carries the result back to the Qt thread. - MainWindow.closeEvent now disconnects all bridge signal connections (tracked in _bridge_connections), removes the theme listener and stops the periodic refresh timer — every logout/login cycle leaked a window that kept handling notifications (duplicate mark_read, tray toasts). - AsyncBridge logout rewires _key_change_cb onto the fresh ChatClient (key-change MITM warning was dead after logout) and clears _pending_send_queue so queued messages cannot be sent under a different identity. - CLI: fix await precedence crash in the react-to-message prompt. Co-Authored-By: Claude Opus 4.8 (1M context) --- client.py | 2 +- gui_client.py | 165 +++++++++++++++++++++++++++++++------------------- 2 files changed, 103 insertions(+), 64 deletions(-) diff --git a/client.py b/client.py index f897615..b674b49 100644 --- a/client.py +++ b/client.py @@ -348,7 +348,7 @@ async def interactive_menu(client: ChatClient): print("[!] Invalid number.") continue print("Reactions: thumbsup, heart, laugh, surprised, sad, thumbsdown") - reaction = await prompt("Reaction: ").strip().lower() + reaction = (await prompt("Reaction: ")).lower() if reaction not in ("thumbsup", "heart", "laugh", "surprised", "sad", "thumbsdown"): print("[!] Invalid reaction.") continue diff --git a/gui_client.py b/gui_client.py index a4aded3..c66a88a 100644 --- a/gui_client.py +++ b/gui_client.py @@ -598,6 +598,7 @@ class AsyncBridge(QThread): connection_error = pyqtSignal(str) login_result = pyqtSignal(bool, str) register_result = pyqtSignal(bool, str) + confirm_result = pyqtSignal(bool, str) # registration code confirmation conversations_loaded = pyqtSignal(list) messages_loaded = pyqtSignal(str, list) # conv_id, messages older_messages_loaded = pyqtSignal(str, list) # conv_id, older messages @@ -849,6 +850,11 @@ class AsyncBridge(QThread): pass self.client = ChatClient() self.client._reencrypt_progress_cb = self._emit_reencrypt_status + self.client._key_change_cb = self._emit_key_change_warning + # Drop messages queued under the previous identity — they must not be + # re-sent from a different account after re-login. + self._pending_send_queue.clear() + self.message_queued.emit(0) try: await self.client.connect() self.client._listener_task = asyncio.create_task(self.client._background_listener()) @@ -2738,6 +2744,10 @@ class MainWindow(QWidget): def _toggle_privacy(self): """Toggle privacy overlay on/off (Ctrl+Shift+P).""" + if self._privacy_locked: + # Session is password-locked — the shortcut must not bypass the + # lock; unlocking requires the password (_on_unlock_attempt). + return self._privacy_enabled = not self._privacy_enabled if not self._privacy_enabled: self._privacy_locked = False @@ -3514,56 +3524,65 @@ class MainWindow(QWidget): main_layout.addLayout(wrapper) def _connect_signals(self): - self.bridge.conversations_loaded.connect(self._on_conversations_loaded) - self.bridge.messages_loaded.connect(self._on_messages_loaded) - self.bridge.older_messages_loaded.connect(self._on_older_messages_loaded) - self.bridge.message_sent.connect(self._on_message_sent) - self.bridge.message_sent_payload.connect(self._on_message_sent_payload) - self.bridge.new_notification.connect(self._on_notification) - self.bridge.add_member_result.connect(self._on_add_member_result) - self.bridge.authorize_result.connect(self._on_authorize_result) - self.bridge.rotate_result.connect(self._on_rotate_result) - self.bridge.password_changed.connect(self._on_password_changed) - self.bridge.username_changed.connect(self._on_username_changed) - self.bridge.reencrypt_status.connect(self._on_reencrypt_status) - self.bridge.messages_read_notification.connect(self._on_messages_read) - self.bridge.message_delivered_notification.connect(self._on_message_delivered) - self.bridge.typing_start_notification.connect(self._on_typing_start) - self.bridge.typing_stop_notification.connect(self._on_typing_stop) - self.bridge.remove_member_result.connect(self._on_remove_member_result) - self.bridge.message_deleted_notification.connect(self._on_message_deleted) - self.bridge.delete_message_result.connect(self._on_delete_message_result) - self.bridge.image_sent.connect(self._on_image_sent) - self.bridge.image_downloaded.connect(self._on_image_downloaded) - self.bridge.image_download_failed.connect(self._on_image_download_failed) - self.bridge.file_sent.connect(self._on_file_sent) - self.bridge.file_downloaded.connect(self._on_file_downloaded) - self.bridge.conversation_updated.connect(self._on_conversation_updated) - self.bridge.connection_state_changed.connect(self._on_connection_state_changed) - self.bridge.group_left.connect(self._on_group_left) - self.bridge.group_renamed.connect(self._on_group_renamed) - self.bridge.conversation_deleted.connect(self._on_conversation_deleted) - self.bridge.avatar_loaded.connect(self._on_avatar_for_conv_list) - self.bridge.avatar_fetch_failed.connect(self._on_avatar_fetch_failed) - self.bridge._avatar_changed_signal.connect(self._on_avatar_changed_push) - self.bridge.invitations_loaded.connect(self._on_invitations_loaded) - self.bridge.invitation_result.connect(self._on_invitation_result) - self.bridge.invitation_received.connect(self._on_invitation_received) - self.bridge.device_added_notification.connect(self._on_device_added) - self.bridge.online_status_changed.connect(self._on_online_status_changed) - self.bridge.online_users_loaded.connect(self._on_online_users_loaded) - self.bridge.group_avatar_loaded.connect(self._on_group_avatar_for_conv_list) - self.bridge.group_avatar_fetch_failed.connect( - lambda cid: self._group_avatar_requested.discard(cid)) - self.bridge.group_avatar_updated.connect(self._on_group_avatar_updated) - self.bridge.session_reset_notification.connect(self._on_session_reset) - self.bridge.reaction_notification.connect(self._on_reaction_notification) - self.bridge.pin_notification.connect(self._on_pin_notification) - self.bridge.unpin_notification.connect(self._on_unpin_notification) - self.bridge.pinned_messages_loaded.connect(self._on_pinned_messages_loaded) - self.bridge.forward_result.connect(self._on_forward_result) - self.bridge.key_change_warning.connect(self._on_key_change_warning) - self.bridge.message_queued.connect(self._on_message_queued) + # Connections to the (shared, long-lived) bridge are recorded so + # closeEvent can disconnect them — otherwise a closed window keeps + # processing every signal and leaks after each logout/login cycle. + self._bridge_connections = [] + + def conn(signal, slot): + signal.connect(slot) + self._bridge_connections.append((signal, slot)) + + conn(self.bridge.conversations_loaded, self._on_conversations_loaded) + conn(self.bridge.messages_loaded, self._on_messages_loaded) + conn(self.bridge.older_messages_loaded, self._on_older_messages_loaded) + conn(self.bridge.message_sent, self._on_message_sent) + conn(self.bridge.message_sent_payload, self._on_message_sent_payload) + conn(self.bridge.new_notification, self._on_notification) + conn(self.bridge.add_member_result, self._on_add_member_result) + conn(self.bridge.authorize_result, self._on_authorize_result) + conn(self.bridge.rotate_result, self._on_rotate_result) + conn(self.bridge.password_changed, self._on_password_changed) + conn(self.bridge.username_changed, self._on_username_changed) + conn(self.bridge.reencrypt_status, self._on_reencrypt_status) + conn(self.bridge.messages_read_notification, self._on_messages_read) + conn(self.bridge.message_delivered_notification, self._on_message_delivered) + conn(self.bridge.typing_start_notification, self._on_typing_start) + conn(self.bridge.typing_stop_notification, self._on_typing_stop) + conn(self.bridge.remove_member_result, self._on_remove_member_result) + conn(self.bridge.message_deleted_notification, self._on_message_deleted) + conn(self.bridge.delete_message_result, self._on_delete_message_result) + conn(self.bridge.image_sent, self._on_image_sent) + conn(self.bridge.image_downloaded, self._on_image_downloaded) + conn(self.bridge.image_download_failed, self._on_image_download_failed) + conn(self.bridge.file_sent, self._on_file_sent) + conn(self.bridge.file_downloaded, self._on_file_downloaded) + conn(self.bridge.conversation_updated, self._on_conversation_updated) + conn(self.bridge.connection_state_changed, self._on_connection_state_changed) + conn(self.bridge.group_left, self._on_group_left) + conn(self.bridge.group_renamed, self._on_group_renamed) + conn(self.bridge.conversation_deleted, self._on_conversation_deleted) + conn(self.bridge.avatar_loaded, self._on_avatar_for_conv_list) + conn(self.bridge.avatar_fetch_failed, self._on_avatar_fetch_failed) + conn(self.bridge._avatar_changed_signal, self._on_avatar_changed_push) + conn(self.bridge.invitations_loaded, self._on_invitations_loaded) + conn(self.bridge.invitation_result, self._on_invitation_result) + conn(self.bridge.invitation_received, self._on_invitation_received) + conn(self.bridge.device_added_notification, self._on_device_added) + conn(self.bridge.online_status_changed, self._on_online_status_changed) + conn(self.bridge.online_users_loaded, self._on_online_users_loaded) + conn(self.bridge.group_avatar_loaded, self._on_group_avatar_for_conv_list) + conn(self.bridge.group_avatar_fetch_failed, + lambda cid: self._group_avatar_requested.discard(cid)) + conn(self.bridge.group_avatar_updated, self._on_group_avatar_updated) + conn(self.bridge.session_reset_notification, self._on_session_reset) + conn(self.bridge.reaction_notification, self._on_reaction_notification) + conn(self.bridge.pin_notification, self._on_pin_notification) + conn(self.bridge.unpin_notification, self._on_unpin_notification) + conn(self.bridge.pinned_messages_loaded, self._on_pinned_messages_loaded) + conn(self.bridge.forward_result, self._on_forward_result) + conn(self.bridge.key_change_warning, self._on_key_change_warning) + conn(self.bridge.message_queued, self._on_message_queued) self._show_verification_dialog_signal.connect(self._show_verification_dialog) # ------------------------------------------------------------------ @@ -6724,7 +6743,9 @@ class MainWindow(QWidget): def _on_image_download_failed(self, file_id): if self._pending_image_download and self._pending_image_download["file_id"] == file_id: self._pending_image_download = None - self.statusBar().showMessage("Image download failed.", 5000) + self.status_bar.setText("Image download failed.") + self._status_bar_conv_id = None + QTimer.singleShot(5000, self._clear_status_bar) def _show_image_dialog(self, image_data, image_info): dlg = QDialog(self) @@ -6872,6 +6893,18 @@ class MainWindow(QWidget): def closeEvent(self, event): if self._tray_icon: self._tray_icon.hide() + # Detach from shared, long-lived objects (bridge, ThemeManager) so a + # closed window stops handling events and can be garbage-collected — + # otherwise every logout/login leaks a window that keeps reacting to + # notifications (duplicate mark_read, tray toasts, periodic refresh). + self._refresh_timer.stop() + tm().remove_listener(self._apply_theme) + for signal, slot in getattr(self, "_bridge_connections", []): + try: + signal.disconnect(slot) + except Exception: + pass + self._bridge_connections = [] if not self._is_logout: self.bridge.stop() self.bridge.wait(2000) @@ -6920,20 +6953,15 @@ def main(): login_win.show_verification_page(hint) def do_confirm(code): + # Read widget values on the Qt thread; the coroutine below runs + # on the AsyncBridge thread and must not touch Qt widgets. + email = login_win._reg_email_input.text().strip() + username = login_win.username_input.text().strip() + async def _confirm(): okc, msgc = await bridge.client.confirm_registration( - login_win._reg_email_input.text().strip(), - login_win.username_input.text().strip(), - code.strip(), - ) - if okc: - login_win.show_success(msgc) - bridge.do_login( - login_win._reg_email_input.text().strip(), - login_win._reg_password_input.text(), - ) - else: - login_win.show_error(msgc) + email, username, code.strip()) + bridge.confirm_result.emit(okc, msgc) bridge.schedule(_confirm()) login_win._confirm_callback = do_confirm @@ -6970,9 +6998,20 @@ def main(): else: login_win.show_error(msg) + def on_confirm_result(okc, msgc): + if okc: + login_win.show_success(msgc) + bridge.do_login( + login_win._reg_email_input.text().strip(), + login_win._reg_password_input.text(), + ) + else: + login_win.show_error(msgc) + bridge.connected.connect(on_connected) bridge.connection_error.connect(on_conn_error) bridge.register_result.connect(on_register_result) + bridge.confirm_result.connect(on_confirm_result) bridge.login_result.connect(on_login_result) bridge.pairing_code.connect(on_pairing_code) bridge.pairing_complete.connect(on_pairing_complete)