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:
filip
2026-06-12 16:08:31 +02:00
parent d499fd8436
commit 4d15799b5e
2 changed files with 103 additions and 64 deletions

View File

@@ -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)