diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index 48a78c4a..6525e2f8 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -25,6 +25,7 @@ def __init__(self, config: Config, is_login_phase=True): self.clipboard_manager = ClipboardManager(self.config) self.cipher_manager = CipherManager(self.config) self.notification_manager = NotificationManager(self.config) + self.request_manager = RequestManager(self.config) self.sys_tray: TaskbarPanel = None self.first_conn_lost = True self.is_login_phase = is_login_phase @@ -32,6 +33,9 @@ def __init__(self, config: Config, is_login_phase=True): self.is_connected = False self.disconnected = False self.is_auto_reconnecting = False + # Guards the "session expired" notification so a credential-less client + # is told once, not on every 10s reconnect attempt. + self.session_expired_notified = False def set_tray_ref(self, sys_tray: TaskbarPanel): """ @@ -52,6 +56,22 @@ def connect(self) -> tuple[bool, str]: try: if self.is_connected: return True, "" + # Honor an explicit user disconnect: an auto-reconnect thread that + # was already sleeping when the user disconnected must not wake up + # and silently re-login/reconnect behind them. manual_reconnect() + # clears this flag before calling, so intentional reconnects still + # proceed. + if self.disconnected: + return False, "Websocket disconnected" + # A restarted server drops its in-memory sessions, invalidating the + # stored cookie. Refresh the session before (re)connecting so the + # handshake is not 302-redirected to the login page — which the + # underlying websocket client would follow into an invalid "http" + # scheme and never recover from. Skipped during the initial login + # phase, where authenticate_and_connect() already owns the login. + if not self.is_login_phase and self.config.data.get("cookie"): + if not self.request_manager.is_session_valid(): + self._try_relogin() self.client = Client( self.config.data["websocket_url"], headers={ @@ -76,6 +96,7 @@ def connect(self) -> tuple[bool, str]: # logging.info("Websocket connected") self.is_connected = True self.is_auto_reconnecting = False + self.session_expired_notified = False if not self.first_conn_lost: self.first_conn_lost = True self.notification_manager.notify( @@ -91,6 +112,35 @@ def connect(self) -> tuple[bool, str]: logging.error(msg) return False, msg + def _try_relogin(self) -> bool: + """ + Recover an expired session before reconnecting. + + When the login password was persisted (Save Password) this silently + re-authenticates and refreshes the cookie/CSRF token in place, so the + following WebSocket connect uses a live session and syncing resumes with + no user action. Without stored credentials it notifies the user once + (they must re-login manually) and returns False. + """ + if self.config.data.get("password") and self.config.data.get("username"): + success, _msg, cookie = self.request_manager.login() + if success and cookie: + self.config.data["cookie"] = cookie + self.config.data["csrf_token"] = self.request_manager.get_csrf_token() + self.config.save() + self.session_expired_notified = False + logging.info("Session had expired; silently re-logged in") + return True + + if not self.session_expired_notified: + self.session_expired_notified = True + self.notification_manager.notify( + title=f"{APP_NAME}: Session Expired 🔒", + message="Your session ended (the server may have restarted). " + "Please re-login to resume clipboard syncing.", + ) + return False + def _on_close(self): self.is_connected = False # Auto Reconnect diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py index 6918974e..0c139335 100644 --- a/ClipCascade_Desktop/src/utils/request_manager.py +++ b/ClipCascade_Desktop/src/utils/request_manager.py @@ -21,6 +21,32 @@ def format_cookie(cookie: dict) -> str: """ return f"JSESSIONID={cookie.get('JSESSIONID', '')};" + def is_session_valid(self) -> bool: + """ + Probe whether the stored session cookie is still authenticated. + + Every protected endpoint 302-redirects to the login page once the + server-side session is gone (e.g. after a server restart drops its + in-memory sessions). Redirect following is disabled so that 302 is + observable instead of being masked by the 200 login page it points to. + Network/other errors return True so the caller falls through to the + normal WebSocket retry path instead of forcing an unnecessary re-login. + """ + try: + response = requests.get( + self.config.data["server_url"] + CSRF_URL, + headers={ + "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) + }, + verify=self._verify(), + allow_redirects=False, + timeout=WEBSOCKET_TIMEOUT / 1000, + ) + return response.status_code == 200 + except Exception as e: + logging.error(f"Error probing session validity: {e}") + return True + def login(self) -> tuple[bool, str, dict]: try: session = requests.Session()