GUI/CLI fixes: crash paths, privacy-lock bypass, threading, logout leaks
- _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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
165
gui_client.py
165
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)
|
||||
|
||||
Reference in New Issue
Block a user