Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions ClipCascade_Desktop/src/stomp_ws/stomp_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,17 @@ 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
self.client = None
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):
"""
Expand All @@ -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={
Expand All @@ -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(
Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions ClipCascade_Desktop/src/utils/request_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down