*/
const ENDPOINTS = {
USER_DETAILS: "/admin/user-details",
+ USER_DETAILS_PAGE: "/admin/user-details/page",
WEBSOCKET_STATS: "/admin/websocket-stats",
+ ONLINE_USERS: "/admin/online-users",
+ ONLINE_USERS_PAGE: "/admin/online-users/page",
BFA_SNAPSHOT: "/admin/bfa-snapshot",
+ BFA_SNAPSHOT_PAGE: "/admin/bfa-snapshot/page",
BFA_SNAPSHOT_FILE: "/admin/bfa-snapshot-file",
UNLOCK_USER: "/admin/unlock-user",
SERVER_TIME: "/admin/server-time",
};
/**
- * A reusable fetch function that handles basic error checking.
- *
- * @param {string} url - The URL endpoint to call.
- * @param {string} [method='GET'] - The HTTP method, default is GET.
- * @param {Object|null} [body=null] - The request body, if any.
- * @returns {Promise} - The fetch response object.
+ * Generic fetch helper with basic error checking.
*/
async function fetchData(url, method = "GET", body = null, headers = {}) {
try {
@@ -216,11 +329,7 @@
Environment Variables
}
/**
- * Format an epoch time in seconds to a human-readable, locale-specific
- * string in the format "YYYY MMM DD HH:MM:SS AM/PM TZ".
- *
- * @param {number} epoch - The epoch time in seconds.
- * @returns {string} - The formatted string, empty if the input is null or 0.
+ * Format epoch (seconds) to a human-readable, locale-specific string.
*/
function formatEpochToLocal(epoch) {
if (epoch == null || epoch === 0) return "";
@@ -245,37 +354,320 @@
Environment Variables
}
/**
- * 1) Fetch and display user details.
+ * Make table columns drag-resizable. Switches the table to fixed layout,
+ * locks each
-#### Extra Config/Advanced Settings (Desktop/Mobile):
-- **Maximum Clipboard Size Local Limit (in bytes)**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. You can set a local size limit by specifying a value in bytes (e.g., 512 KiB = 524288 bytes) to test different thresholds suitable for your device. This local limit works alongside the server-specified limit to ensure smoother operation without crashes. For example, on Android (particularly on the Pixel 6a as of 2024), the platform limit(for text) is typically less than 1 MiB. Since the server limit cannot go below 1 MiB, setting the local limit to around 900,000 bytes on the Pixel 6a can help prevent crashes.
+
+
+#### Login and advanced settings (Desktop/Mobile):
+
+Use **Log in** to connect. Select **Show advanced settings** to expand additional options and **Hide advanced settings** to collapse them. On desktop, hover over settings for explanations. On Android, tap a label with a small **?** to see its explanation, or use **HELP** to open this documentation.
+
+Connection status notifications appear on the main login screen, outside advanced settings, on both desktop and Android. Desktop enables them by default; Android disables them by default. Encryption is also on the main login screen and is enabled by default on both platforms.
+
+- **Local clipboard size limit (bytes)**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. You can set a local size limit by specifying a value in bytes (e.g., 512 KiB = 524288 bytes) to test different thresholds suitable for your device. This local limit works alongside the server-specified limit to ensure smoother operation without crashes. For example, on Android (particularly on the Pixel 6a as of 2024), the platform limit(for text) is typically less than 1 MiB. Since the server limit cannot go below 1 MiB, setting the local limit to around 900,000 bytes on the Pixel 6a can help prevent crashes.
- **Store Password Locally (not recommended)**: Enable this option if you frequently encounter session logouts. While the app stores session cookies for an extended period, a server restart may prompt a re-login. If re-entering the password becomes tedious, you can use this option to store your password locally for convenience.
> Note: This option will only work if encryption is disabled, as encryption requires the raw password to generate a password hash.
- **Enable Image Sharing and Enable File Sharing**: Enabling these options allows the app to send images or files. However, the app will continue to receive images and files even if these options are disabled.
-- **Enable Notification**: Turn on this option to receive notifications about WebSocket disconnections and reconnections.
-- **Enable Encryption (recommended)**: Enabling this option activates end-to-end encryption for clipboard data. This ensures that all clipboard content is encrypted before leaving your device. Refer to the section below on E2E encryption for detailed instructions on how it works and how to configure the `salt` and `hash rounds`.
+- **Show connection status notifications** (Desktop): Turn on this option to receive notifications about WebSocket disconnections and reconnections.
+- **Enable encryption (recommended)**: Enabling this option activates end-to-end encryption for clipboard data. This ensures that all clipboard content is encrypted before leaving your device. Refer to the section below on E2E encryption for detailed instructions on how it works and how to configure the encryption salt and key derivation iterations.
-- **SSL CA bundle**: Use a PEM/CRT certificate bundle when your server uses a private/internal CA (for example, corporate PKI). Leave it empty to use default public CA/OS trust. The bundle affects HTTPS/WSS verification only; HTTP/WS connections are still allowed, and TLS certificate and hostname verification remain enabled.
+- **Custom CA certificate bundle**: Use a PEM/CRT certificate bundle when your server uses a private/internal CA (for example, corporate PKI). Leave it empty to use default public CA/OS trust. The bundle affects HTTPS/WSS verification only; HTTP/WS connections are still allowed, and TLS certificate and hostname verification remain enabled.
- **Desktop:** Enter the path to a PEM file containing your root CA or certificate chain.
+ **Desktop:** Enter the path to a PEM file containing your root CA or certificate chain, or use **BrowseâĻ** to select it. The picker offers PEM/CRT filters and an **All files** option; the bundle must contain PEM-encoded certificates. Cancelling keeps the current path unchanged. Use the **trash button** to remove the path and log in to use default certificate verification.
**Android:** Tap **Import** to select a certificate bundle (up to 1 MiB). The app saves a copy after successful login and restores it for background connections. Imported certificates are trusted alongside system CAs only for the configured server hostname; other hostnames use system trust. Tap **Replace** to import another bundle, or **Remove** and log in to return to system trust only.
#### Desktop (Specific):
- - **Default File Download Location**: When this path is set, the app will save files directly to the specified location without prompting the user each time the "Download Files" button is clicked.
+ - **Default download folder**: Type an existing folder path or use **BrowseâĻ** to select one. When this path is set, the app will save files directly to the specified location without prompting the user each time the "Download Files" button is clicked. Cancelling the picker keeps the current path unchanged. Use the **trash button** to choose a folder for each download.
+
+ The **BrowseâĻ** buttons use Tkinter dialogs on Windows, macOS, and Linux desktop sessions. Manual path entry remains available. The Linux CLI continues to use typed paths and does not require a graphical picker.
#### Android (Specific):
- - **Run on System Startup**: Enable this option to allow the app to automatically start on system reboot. By default, this option is disabled. If you are using the [ADB](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) workaround, keep this option disabled to avoid issues with the READ_LOGS permission [popup](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) being dismissed, which prevents clipboard monitoring in the background.
- - **Enable WebSocket Status Notification**: Receive alerts when the WebSocket connection is lost or restored, ensuring you're informed about any connection disruptions.
+ - **Start on device restart (disable if the READ_LOGS permission is granted via ADB)**: Enable this option to allow the app to automatically start on system reboot. By default, this option is disabled. If you are using the [ADB](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) workaround, keep this option disabled to avoid issues with the READ_LOGS permission [popup](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) being dismissed, which prevents clipboard monitoring in the background.
+ - **Show connection status notifications**: Receive alerts when the WebSocket connection is lost or restored, ensuring you're informed about any connection disruptions.
@@ -1108,13 +1117,13 @@ When encryption is enabled, clipboard data is encrypted directly on the client d
Enabling the encryption option is all that's needed to activate encryption by default. However, advanced users have the option to further customize the encryption process by adjusting the following parameters:
-- **Salt**: An optional string used as an additional input in the hashing process. For example, you could use a unique string like `"myCustomSalt123"` to enhance encryption security.
+- **Encryption salt**: An optional string used as an additional input in the hashing process. For example, you could use a unique string like `"myCustomSalt123"` to enhance encryption security.
-- **Hash Rounds**: An integer that specifies the number of hashing iterations. Increasing the hash rounds strengthens encryption by making it more computationally demanding. You can increase the default value of 664,937 to a higher number, such as 1,000,000, to boost security.
+- **Key derivation iterations**: An integer that specifies the number of hashing iterations. Increasing the number of iterations strengthens encryption by making it more computationally demanding. You can increase the default value of 664,937 to a higher number, such as 1,000,000, to boost security.
-It is crucial to ensure the same **salt** and **hash rounds** are used across all client devices to maintain compatibility.
+It is crucial to ensure the same **encryption salt** and **key derivation iterations** are used across all client devices to maintain compatibility.
-You can adjust these settings in the **Extra Config** section on the login page for users who require enhanced encryption options.
+You can adjust these settings in the **advanced settings** section on the login page for users who require enhanced encryption options.
From 1957015eb658258b71e8f8f21e129658da5fe187 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Thu, 22 Oct 2026 23:33:54 -0500
Subject: [PATCH 21/28] feat: add periodic client update checks and
configurable alerts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Check for updates at startup and every 24 hours on desktop and Android.
- Retry failed checks after one hour without blocking the app.
- Enable update alerts by default, with a toggle at the bottom of advanced settings.
- Show foreground popups, background notifications, or CLI terminal messages.
- Alert once per version per session and prevent duplicate Android checks.
- Show updates only when the published version is newer, including on the server webpage.
- Share Androidâs version constant between the UI and service, and update release CI.
---
.github/scripts/apply_app_version.py | 4 +-
.github/workflows/README.md | 2 +-
.github/workflows/release.yml | 2 +-
ClipCascade_Desktop/src/cli/login.py | 7 +
ClipCascade_Desktop/src/cli/tray.py | 13 +-
ClipCascade_Desktop/src/core/application.py | 24 +--
ClipCascade_Desktop/src/core/config.py | 3 +-
ClipCascade_Desktop/src/gui/login.py | 41 ++++-
ClipCascade_Desktop/src/gui/tray.py | 14 ++
.../src/utils/notification_manager.py | 13 +-
.../src/utils/request_manager.py | 4 +-
.../src/utils/update_checker.py | 65 ++++++++
ClipCascade_Mobile/src/App.js | 84 ++++++----
ClipCascade_Mobile/src/AppVersion.js | 2 +
ClipCascade_Mobile/src/DevicePreferences.js | 1 +
.../src/StartForegroundService.js | 5 +
ClipCascade_Mobile/src/UpdateChecker.js | 146 ++++++++++++++++++
.../main/resources/static/assets/js/main.js | 32 +++-
18 files changed, 406 insertions(+), 56 deletions(-)
create mode 100644 ClipCascade_Desktop/src/utils/update_checker.py
create mode 100644 ClipCascade_Mobile/src/AppVersion.js
create mode 100644 ClipCascade_Mobile/src/UpdateChecker.js
diff --git a/.github/scripts/apply_app_version.py b/.github/scripts/apply_app_version.py
index 0980ff6c..fb619f19 100644
--- a/.github/scripts/apply_app_version.py
+++ b/.github/scripts/apply_app_version.py
@@ -6,7 +6,7 @@
Targets:
- ClipCascade_Desktop/src/core/constants.py (windows, macos, linux_gui, linux_non_gui)
- - ClipCascade_Mobile/src/App.js (android)
+ - ClipCascade_Mobile/src/AppVersion.js (android)
- ClipCascade_Mobile/src/android/app/build.gradle (android versionName + versionCode)
- ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java (server)
@@ -76,7 +76,7 @@ def patch_constants_py() -> None:
def patch_app_js() -> None:
- path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "App.js"
+ path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "AppVersion.js"
replace_in_file(
path,
r"const APP_VERSION = '[^']+';",
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 390ca0c1..bf6815f9 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -131,7 +131,7 @@ the same source constants on `main`:
| version.json key | Synced into |
|----------------------------------|------------------------------------------------------------------------------|
| `windows` / `macos` / `linux_*` | `ClipCascade_Desktop/src/core/constants.py` (`APP_VERSION` per platform) |
-| `android` | `ClipCascade_Mobile/src/App.js` (`APP_VERSION`) |
+| `android` | `ClipCascade_Mobile/src/AppVersion.js` (`APP_VERSION`) |
| `android` | `âĻ/android/app/build.gradle` (`versionName` + computed `versionCode`) |
| `server` | `âĻ/constants/ServerConstants.java` (`APP_VERSION`) |
| `server` | `ClipCascade_Server/docker-compose/docker-compose.yml` (image tag) |
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5d9e51ae..23535538 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -633,7 +633,7 @@ jobs:
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add version.json \
ClipCascade_Desktop/src/core/constants.py \
- ClipCascade_Mobile/src/App.js \
+ ClipCascade_Mobile/src/AppVersion.js \
ClipCascade_Mobile/src/android/app/build.gradle \
ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java \
ClipCascade_Server/docker-compose/docker-compose.yml
diff --git a/ClipCascade_Desktop/src/cli/login.py b/ClipCascade_Desktop/src/cli/login.py
index a4d5bcdb..14d04417 100644
--- a/ClipCascade_Desktop/src/cli/login.py
+++ b/ClipCascade_Desktop/src/cli/login.py
@@ -82,6 +82,13 @@ def mainloop(self):
CustomDialog("Logging in...").mainloop()
return
+ self.config.data["update_notifications"] = LoginForm.str_to_bool(
+ input(
+ f"Notify when an update is available [{LoginForm.bool_to_str(self.config.data['update_notifications'])}]: "
+ ) or LoginForm.bool_to_str(self.config.data["update_notifications"])
+ )
+ self._save_preference("update_notifications")
+
# Validate hash_rounds
while True:
hash_rounds = input(
diff --git a/ClipCascade_Desktop/src/cli/tray.py b/ClipCascade_Desktop/src/cli/tray.py
index 1b956a9b..fd07999a 100644
--- a/ClipCascade_Desktop/src/cli/tray.py
+++ b/ClipCascade_Desktop/src/cli/tray.py
@@ -195,7 +195,18 @@ def update_stats(self):
threading.Thread(target=self._update_stats_thread, daemon=True).start()
def _update_stats_thread(self):
- while True:
+ while not self.loop_terminate:
+ checker = getattr(self, "update_checker", None)
+ if checker:
+ if checker.status is not None and checker.status != self.new_version_available:
+ self.new_version_available = checker.status
+ self.update_menu(no_dialog=True)
+ status = checker.claim_alert(self.config.data.get("update_notifications", True))
+ if status:
+ CustomDialog(
+ f"ClipCascade {status[1]} is available. Press Enter to refresh the update menu.",
+ msg_type="info",
+ ).mainloop()
current_stats = self.ws_interface.get_stats()
if current_stats is not None and self.previous_stats != current_stats:
self.previous_stats = current_stats
diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py
index 2e60d651..6dd0745e 100644
--- a/ClipCascade_Desktop/src/core/application.py
+++ b/ClipCascade_Desktop/src/core/application.py
@@ -9,6 +9,7 @@
from core.config import Config
from utils.request_manager import RequestManager
from utils.cipher_manager import CipherManager
+from utils.update_checker import UpdateChecker, parse_version
from stomp_ws.stomp_manager import STOMPManager
from p2p.p2p_manager import P2PManager
@@ -40,6 +41,7 @@ def __init__(
mutex_identifier=MUTEX_NAME,
):
self._macos_shutdown_complete = False
+ self.update_checker = UpdateChecker(self.get_version_update_status)
try:
self.log_file_path = os.path.join(
get_program_files_directory(), log_file_path
@@ -228,6 +230,8 @@ def authenticate_and_connect(self):
else lambda: sys.exit(0)
),
)
+ if not (PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI):
+ login_form.watch_updates(self.update_checker)
login_form.mainloop() # wait until login form is closed
raw_password = self.config.data[
"password"
@@ -289,16 +293,17 @@ def _get_ws_manager(self):
else:
return self.stomp_manager
- def get_version_update_status(self) -> list:
+ def get_version_update_status(self) -> list | None:
"""
Checks for a new version of the application by comparing the current version
with the one available in a remote JSON file.
Returns:
- list: [bool, str, str, str] - [Is new version available, latest version, current version, release URL]
+ [Is new version available, latest version, current version, release URL],
+ or None when the check fails.
"""
try:
- response = RequestManager.get(VERSION_URL)
+ response = RequestManager.get(VERSION_URL, timeout=(5, 10))
response_data = response.json()
if PLATFORM == WINDOWS:
key = "windows"
@@ -311,12 +316,11 @@ def get_version_update_status(self) -> list:
key = "linux_non_gui"
latest_version = response_data[key]
- if not isinstance(latest_version, str) or not latest_version.strip():
- raise ValueError("Invalid latest version")
- return [latest_version != APP_VERSION, latest_version, APP_VERSION, RELEASE_URL]
+ newer = parse_version(latest_version) > parse_version(APP_VERSION)
+ return [newer, latest_version.strip(), APP_VERSION, RELEASE_URL]
except Exception as e:
logging.error(f"Error checking for new version: {e}")
- return [False, "", APP_VERSION, RELEASE_URL]
+ return None # Unknown/offline: retain the last successful result.
def get_donation_url(self) -> str:
try:
@@ -370,22 +374,23 @@ def run(self):
self.setup_logging()
self.ensure_single_instance()
self.config.load()
+ self.update_checker.start()
self.authenticate_and_connect()
self.config.save()
- update_available = self.get_version_update_status()
donation_url = self.get_donation_url()
sys_tray = TaskbarPanel(
on_connect_callback=self._get_ws_manager().manual_reconnect,
on_disconnect_callback=self._get_ws_manager().disconnect,
on_logoff_callback=self.logoff_and_exit,
- new_version_available=update_available,
+ new_version_available=self.update_checker.status,
github_url=GITHUB_URL,
donation_url=donation_url,
ws_interface=self._get_ws_manager(),
config=self.config,
**({"on_shutdown_callback": self._shutdown_macos} if PLATFORM == MACOS else {}),
)
+ sys_tray.update_checker = self.update_checker
self._get_ws_manager().set_tray_ref(sys_tray)
sys_tray.run()
except Exception as e:
@@ -395,6 +400,7 @@ def run(self):
msg + "\nCheck logs in project directory", msg_type="error"
).mainloop()
finally:
+ self.update_checker.stop()
if not (PLATFORM == MACOS and self._macos_shutdown_complete):
try:
self._get_ws_manager().disconnect()
diff --git a/ClipCascade_Desktop/src/core/config.py b/ClipCascade_Desktop/src/core/config.py
index f7fbca0d..a6fe9765 100644
--- a/ClipCascade_Desktop/src/core/config.py
+++ b/ClipCascade_Desktop/src/core/config.py
@@ -8,7 +8,7 @@
class Config:
DEVICE_PREFERENCE_FIELDS = frozenset({
- "notification", "save_password", "max_clipboard_size_local_limit_bytes",
+ "notification", "update_notifications", "save_password", "max_clipboard_size_local_limit_bytes",
"enable_image_sharing", "enable_file_sharing", "default_file_download_location",
})
@@ -26,6 +26,7 @@ def __init__(self, file_name=DATA_FILE_NAME):
"salt": "",
"csrf_token": "",
"notification": True,
+ "update_notifications": True,
"save_password": False,
"password": "",
"max_clipboard_size_local_limit_bytes": None,
diff --git a/ClipCascade_Desktop/src/gui/login.py b/ClipCascade_Desktop/src/gui/login.py
index 09f053ce..32c7acd4 100644
--- a/ClipCascade_Desktop/src/gui/login.py
+++ b/ClipCascade_Desktop/src/gui/login.py
@@ -1,11 +1,14 @@
import re
+import threading
+import webbrowser
import tkinter as tk
-from tkinter import ttk, font, filedialog
+from tkinter import ttk, font, filedialog, messagebox
import gc
import time
import os
from utils.window_manager import center_window
+from utils.notification_manager import NotificationManager
from core.config import Config
from gui.info import CustomDialog
from core.constants import *
@@ -57,6 +60,24 @@ def _hide(self, _event=None):
class LoginForm(tk.Tk):
+ def watch_updates(self, checker):
+ """Poll on Tk's UI thread; never touch Tk from the network worker."""
+ def poll():
+ status = checker.claim_alert(self.config.data.get("update_notifications", True))
+ if status:
+ message = f"ClipCascade {status[1]} is available (installed: {status[2]})."
+ if self.focus_displayof() is not None:
+ if messagebox.askyesno("Update available", message + "\nOpen the download page?", parent=self):
+ webbrowser.open(status[3])
+ else:
+ threading.Thread(
+ target=NotificationManager(self.config).notify,
+ args=("ClipCascade update available", message + " Use the tray menu to open the download page."),
+ kwargs={"preference": "update_notifications"}, daemon=True,
+ ).start()
+ self._update_poll_id = self.after(1000, poll)
+ self._update_poll_id = self.after(1000, poll)
+
def __init__(
self,
config: Config,
@@ -489,12 +510,26 @@ def __init__(
"Clear the custom CA certificate bundle.\nLog in to use default certificate trust.",
)
+ update_label = ttk.Label(self.extra_frame, text="Notify when an update is available:")
+ update_label.grid(row=8, column=0, padx=(0, 10), pady=5, sticky=tk.W)
+ self.update_notifications_var = tk.BooleanVar(value=self.config.data["update_notifications"])
+ update_checkbox = ttk.Checkbutton(
+ self.extra_frame, variable=self.update_notifications_var, takefocus=False,
+ )
+ update_checkbox.grid(row=8, column=1, padx=10, pady=5, sticky=tk.W)
+ self._add_tooltip(
+ [update_label, update_checkbox],
+ "Checks at startup and once a day while running.\n"
+ "Alerts once per new version each session. Enabled by default.",
+ )
+
# Configure grid weights for extra_frame
self.extra_frame.columnconfigure(1, weight=1)
# Bind after initialization so opening the form never writes defaults.
for field, variable in (
("notification", self.notification_var),
+ ("update_notifications", self.update_notifications_var),
("save_password", self.save_password_var),
("enable_image_sharing", self.enable_image_sharing_var),
("enable_file_sharing", self.enable_file_sharing_var),
@@ -732,6 +767,7 @@ def on_login(self):
)
self.config.data["cipher_enabled"] = self.cipher_var.get()
self.config.data["notification"] = self.notification_var.get()
+ self.config.data["update_notifications"] = self.update_notifications_var.get()
# extra fields
@@ -801,6 +837,9 @@ def on_quit(self):
self.close()
def close(self):
+ if getattr(self, "_update_poll_id", None):
+ self.after_cancel(self._update_poll_id)
+ self._update_poll_id = None
self.destroy()
gc.collect()
time.sleep(0.5)
diff --git a/ClipCascade_Desktop/src/gui/tray.py b/ClipCascade_Desktop/src/gui/tray.py
index ad36abb8..92f83a3e 100644
--- a/ClipCascade_Desktop/src/gui/tray.py
+++ b/ClipCascade_Desktop/src/gui/tray.py
@@ -9,6 +9,7 @@
from PIL import Image, ImageDraw
from utils.main_thread import on_main_thread
+from utils.notification_manager import NotificationManager
from core.config import Config
from gui.info import CustomDialog
from core.constants import *
@@ -282,6 +283,19 @@ def update_stats(self):
def _update_stats_thread(self):
while not self._stopped.is_set():
+ checker = getattr(self, "update_checker", None)
+ if checker:
+ if checker.status is not None and checker.status != self.new_version_available:
+ self.new_version_available = checker.status
+ self.update_menu()
+ status = checker.claim_alert(self.config.data.get("update_notifications", True))
+ if status:
+ # The desktop client has no visible main window in tray mode.
+ NotificationManager(self.config).notify(
+ "ClipCascade update available",
+ f"Version {status[1]} is available. Open the tray menu to update.",
+ preference="update_notifications",
+ )
current_stats = self.ws_interface.get_stats()
if current_stats is not None and self.previous_stats != current_stats:
self.previous_stats = current_stats
diff --git a/ClipCascade_Desktop/src/utils/notification_manager.py b/ClipCascade_Desktop/src/utils/notification_manager.py
index 89cf6361..dfd53b73 100644
--- a/ClipCascade_Desktop/src/utils/notification_manager.py
+++ b/ClipCascade_Desktop/src/utils/notification_manager.py
@@ -16,8 +16,9 @@ class NotificationManager:
def __init__(self, config: Config):
self.config = config
- def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10):
- if self.config.data["notification"]: # Check if notifications are enabled
+ def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10,
+ preference="notification"):
+ if self.config.data.get(preference, True):
try:
if PLATFORM == WINDOWS or (
PLATFORM.startswith(LINUX) and not LINUX_USE_CLI_UI
@@ -35,8 +36,12 @@ def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10)
[
"osascript",
"-e",
- f'display notification "{message}" with title "{title}"',
- ]
+ "on run argv\n"
+ "display notification (item 2 of argv) with title (item 1 of argv)\n"
+ "end run",
+ title, message,
+ ],
+ timeout=10,
)
except Exception as e:
logging.error(f"Failed to handle notification: {e}")
diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py
index 677271af..f4d82df0 100644
--- a/ClipCascade_Desktop/src/utils/request_manager.py
+++ b/ClipCascade_Desktop/src/utils/request_manager.py
@@ -181,12 +181,12 @@ def get_csrf_token(self) -> str:
return ""
@staticmethod
- def get(url: str, headers: dict = None, verify=True) -> requests.Response:
+ def get(url: str, headers: dict = None, verify=True, timeout=None) -> requests.Response:
"""
A generic GET mapper for handling GET requests.
"""
try:
- response = requests.get(url, headers=headers, verify=verify)
+ response = requests.get(url, headers=headers, verify=verify, timeout=timeout)
response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code
return response
except Exception as e:
diff --git a/ClipCascade_Desktop/src/utils/update_checker.py b/ClipCascade_Desktop/src/utils/update_checker.py
new file mode 100644
index 00000000..44704e17
--- /dev/null
+++ b/ClipCascade_Desktop/src/utils/update_checker.py
@@ -0,0 +1,65 @@
+"""One immediate check, daily refreshes, and session-scoped update alerts."""
+import logging
+import re
+import threading
+import time
+
+
+CHECK_INTERVAL = 24 * 60 * 60
+RETRY_INTERVAL = 60 * 60
+
+
+def parse_version(value):
+ # Published client versions are stable major.minor.patch releases.
+ if not isinstance(value, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", value.strip()):
+ raise ValueError("Invalid release version")
+ return tuple(int(part) for part in value.strip().split("."))
+
+
+class UpdateChecker:
+ def __init__(self, fetch_status):
+ self.fetch_status = fetch_status
+ self.status = None
+ self._lock = threading.Lock()
+ self._alerted = set()
+ self._stopped = threading.Event()
+ self._thread = None
+
+ def start(self):
+ if self._thread is None:
+ self._thread = threading.Thread(target=self._run, daemon=True, name="update-checker")
+ self._thread.start()
+
+ def stop(self):
+ self._stopped.set()
+
+ def claim_alert(self, enabled):
+ """UI callers claim only after deciding how to present an alert."""
+ with self._lock:
+ if not enabled or not self.status or not self.status[0]:
+ return None
+ version = self.status[1]
+ if version in self._alerted:
+ return None
+ self._alerted.add(version)
+ return self.status.copy()
+
+ def _run(self):
+ while not self._stopped.is_set():
+ interval = self._check_once()
+ # Short waits also recover promptly after the computer wakes from sleep.
+ deadline = time.time() + interval
+ while not self._stopped.wait(min(60, max(0, deadline - time.time()))):
+ if time.time() >= deadline:
+ break
+
+ def _check_once(self):
+ try:
+ status = self.fetch_status()
+ if status is not None:
+ with self._lock:
+ self.status = status
+ return CHECK_INTERVAL
+ except Exception:
+ logging.exception("Update check failed")
+ return RETRY_INTERVAL
diff --git a/ClipCascade_Mobile/src/App.js b/ClipCascade_Mobile/src/App.js
index a6908f75..46bc6343 100644
--- a/ClipCascade_Mobile/src/App.js
+++ b/ClipCascade_Mobile/src/App.js
@@ -1,5 +1,6 @@
import {
PermissionsAndroid,
+ AppState,
Image,
StyleSheet,
Text,
@@ -40,6 +41,11 @@ import {
clearAsyncStorage,
} from './AsyncStorageManagement';
import StartForegroundService from './StartForegroundService';
+import {APP_VERSION} from './AppVersion';
+import {
+ checkForUpdates, presentUpdate, setUpdateFocused, subscribeToUpdates,
+ RELEASE_URL, UPDATE_NOTIFICATION_ID,
+} from './UpdateChecker';
import {
DEVICE_PREFERENCE_FIELDS,
isValidClipboardLimit,
@@ -64,6 +70,8 @@ const isCancel = error =>
* (file) android\app\src\main\java\com\clipcascade\NativeBridgeModule.kt
* (file) android\app\src\main\java\com\clipcascade\NativeBridgePackage.kt
* (file) android\app\src\main\java\com\clipcascade\ScheduleService.kt
+ * (file) android/app/src/main/java/com/clipcascade/ServerTls.kt
+ * (file) android/app/src/main/java/com/clipcascade/ServerTlsModule.kt
* (file) android\app\src\main\AndroidManifest.xml
* (folder) android\app\src\main\res\
* (file) android\app\build.gradle
@@ -73,11 +81,11 @@ const isCancel = error =>
* (file) HeadlessTask.js
* (file) index.js
* (file) StartForegroundService.js
+ * (file) DevicePreferences.js
+ * (file) AppVersion.js
+ * (file) UpdateChecker.js
*/
-// App version
-const APP_VERSION = '3.2.0';
-
// Main App
function InfoLabel({style, label, info}) {
const infoColor = useColorScheme() === 'dark' ? '#64b5f6' : '#005ea8';
@@ -120,18 +128,40 @@ export default function App() {
const VALIDATE_URL = '/validate-session';
const WEBSOCKET_ENDPOINT_P2P = '/p2psignaling';
const STUN_URL = '/stun-url';
- const VERSION_URL =
- 'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json';
const GITHUB_URL = 'https://github.com/Sathvik-Rao/ClipCascade';
- const RELEASE_URL =
- 'https://github.com/Sathvik-Rao/ClipCascade/releases/latest';
const APP_NAME = 'ClipCascade';
const HELP_URL = `${GITHUB_URL}/blob/main/README.md`;
const METADATA_URL =
'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/metadata.json';
- // Request permissions for notifications
- PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
+ useEffect(() => {
+ setUpdateFocused(true);
+ PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS)
+ .then(() => presentUpdate()).catch(console.warn);
+ const unsubscribe = subscribeToUpdates(setNewVersionAvailable);
+ // Check immediately on UI launch, sharing any in-flight service request.
+ checkForUpdates({force: true});
+ const timer = setInterval(() => checkForUpdates(), 60000);
+ const state = AppState.addEventListener('change', value => {
+ if (value === 'active') {
+ setUpdateFocused(true);
+ checkForUpdates();
+ presentUpdate();
+ }
+ });
+ const focus = AppState.addEventListener('focus', () => {
+ setUpdateFocused(true);
+ presentUpdate();
+ });
+ const blur = AppState.addEventListener('blur', () => setUpdateFocused(false));
+ return () => {
+ unsubscribe();
+ clearInterval(timer);
+ state.remove();
+ focus.remove();
+ blur.remove();
+ };
+ }, []);
const fetchTimeout = async (input, init, timeout_ms = FETCH_TIMEOUT) => {
try {
@@ -160,6 +190,7 @@ export default function App() {
max_clipboard_size_local_limit_bytes: '',
relaunch_on_boot: 'false',
enable_websocket_status_notification: 'false',
+ update_notifications: 'true',
enable_periodic_checks: 'true',
enable_image_sharing: 'true',
enable_file_sharing: 'true',
@@ -335,23 +366,6 @@ export default function App() {
}
}
- // check for new version
- try {
- const response = await fetchTimeout(VERSION_URL);
- if (!response.ok) {
- throw new Error('Network response was not ok');
- }
- const data = await response.json();
- if (data && typeof data.android === 'string' && data.android.trim()) {
- setNewVersionAvailable([
- data.android !== APP_VERSION,
- data.android,
- ]);
- }
- } catch (e) {
- // Silent catch
- }
-
try {
const response = await fetchTimeout(METADATA_URL);
if (!response.ok) {
@@ -837,6 +851,12 @@ export default function App() {
'ClipCascade_WebSocket_Status_Notification_Id',
);
}
+ if (field === 'update_notifications' && value === 'false') {
+ await notifee.cancelNotification(UPDATE_NOTIFICATION_ID);
+ }
+ if (field === 'update_notifications' && value === 'true') {
+ await presentUpdate();
+ }
})
.catch(e =>
setLoginStatusMessage('â Unable to save preference: ' + e),
@@ -1297,6 +1317,18 @@ export default function App() {
)}
+
+
+ handleInputChange('update_notifications', String(value))}
+ />
+
>
)}
{/* Footer */}
diff --git a/ClipCascade_Mobile/src/AppVersion.js b/ClipCascade_Mobile/src/AppVersion.js
new file mode 100644
index 00000000..70606b6a
--- /dev/null
+++ b/ClipCascade_Mobile/src/AppVersion.js
@@ -0,0 +1,2 @@
+// Shared by the app screen and headless clipboard service; patched by release CI.
+export const APP_VERSION = '3.2.0';
diff --git a/ClipCascade_Mobile/src/DevicePreferences.js b/ClipCascade_Mobile/src/DevicePreferences.js
index 4958e344..5fa08ef9 100644
--- a/ClipCascade_Mobile/src/DevicePreferences.js
+++ b/ClipCascade_Mobile/src/DevicePreferences.js
@@ -5,6 +5,7 @@ export const DEVICE_PREFERENCE_FIELDS = new Set([
'max_clipboard_size_local_limit_bytes',
'relaunch_on_boot',
'enable_websocket_status_notification',
+ 'update_notifications',
'enable_periodic_checks',
'enable_image_sharing',
'enable_file_sharing',
diff --git a/ClipCascade_Mobile/src/StartForegroundService.js b/ClipCascade_Mobile/src/StartForegroundService.js
index 0dc89396..71c5a7df 100644
--- a/ClipCascade_Mobile/src/StartForegroundService.js
+++ b/ClipCascade_Mobile/src/StartForegroundService.js
@@ -17,6 +17,7 @@ import {
RTCSessionDescription,
} from 'react-native-webrtc';
import Clipboard from '@react-native-clipboard/clipboard';
+import {checkForUpdates} from './UpdateChecker';
import {
setDataInAsyncStorage,
@@ -1529,6 +1530,10 @@ module.exports = async (inputData = null) => {
break;
}
+ // The shared checker gates requests to once a day and deduplicates
+ // concurrent screen/service checks. Never delay clipboard polling.
+ checkForUpdates();
+
if (isP2PStatusMsgChanged) {
isP2PStatusMsgChanged = false;
await setDataInAsyncStorage(
diff --git a/ClipCascade_Mobile/src/UpdateChecker.js b/ClipCascade_Mobile/src/UpdateChecker.js
new file mode 100644
index 00000000..63aaeb5f
--- /dev/null
+++ b/ClipCascade_Mobile/src/UpdateChecker.js
@@ -0,0 +1,146 @@
+import {Alert, AppState, Linking} from 'react-native';
+import notifee, {AndroidImportance} from '@notifee/react-native';
+import {getDataFromAsyncStorage} from './AsyncStorageManagement';
+import {APP_VERSION} from './AppVersion';
+
+export const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000;
+const RETRY_INTERVAL = 60 * 60 * 1000;
+const VERSION_URL =
+ 'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json';
+export const RELEASE_URL =
+ 'https://github.com/Sathvik-Rao/ClipCascade/releases/latest';
+export const UPDATE_NOTIFICATION_ID = 'ClipCascade_Update';
+
+export const parseVersion = value => {
+ if (typeof value !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(value.trim())) {
+ throw new Error('Invalid release version');
+ }
+ const parts = value.trim().split('.').map(Number);
+ if (!parts.every(Number.isSafeInteger)) {
+ throw new Error('Invalid release version');
+ }
+ return parts;
+};
+
+export const isNewerVersion = (latest, current) => {
+ const remote = parseVersion(latest);
+ const installed = parseVersion(current);
+ for (let i = 0; i < remote.length; i++) {
+ if (remote[i] !== installed[i]) {
+ return remote[i] > installed[i];
+ }
+ }
+ return false;
+};
+
+// One checker per JS runtime, shared by the screen and foreground service.
+let nextCheckAt = 0;
+let inFlight = null;
+let status = [false, ''];
+let alertInFlight = false;
+let popupOpen = false;
+let focused = true;
+const alertedVersions = new Set();
+const listeners = new Set();
+
+export const subscribeToUpdates = listener => {
+ listeners.add(listener);
+ listener(status);
+ return () => listeners.delete(listener);
+};
+
+export const presentUpdate = async () => {
+ const version = status[1];
+ if (!status[0] || alertInFlight || alertedVersions.has(version)) {
+ return;
+ }
+ alertInFlight = true;
+ try {
+ if ((await getDataFromAsyncStorage('update_notifications')) === 'false') {
+ return;
+ }
+ // Read state after storage access: the app may have moved into the background.
+ if (listeners.size && AppState.currentState === 'active' && focused) {
+ if (popupOpen) {
+ return;
+ }
+ const dismiss = () => { popupOpen = false; };
+ Alert.alert(
+ 'Update available',
+ `ClipCascade ${version} is available (installed: ${APP_VERSION}).`,
+ [
+ {text: 'Later', style: 'cancel', onPress: dismiss},
+ {text: 'Open download page', onPress: () => {
+ dismiss();
+ Linking.openURL(RELEASE_URL).catch(console.warn);
+ }},
+ ],
+ {cancelable: true, onDismiss: dismiss},
+ );
+ popupOpen = true;
+ } else {
+ // Do not consume the alert if Android notification permission is denied.
+ const settings = await notifee.getNotificationSettings();
+ if (settings.authorizationStatus < 1) {
+ return;
+ }
+ const channelId = await notifee.createChannel({
+ id: 'clipcascade_updates', name: 'App updates',
+ importance: AndroidImportance.DEFAULT,
+ });
+ if ((await notifee.getChannel(channelId))?.blocked) {
+ return;
+ }
+ await notifee.displayNotification({
+ id: UPDATE_NOTIFICATION_ID,
+ title: 'ClipCascade update available',
+ body: `Version ${version} is available. Tap to open ClipCascade.`,
+ android: {
+ channelId, smallIcon: 'ic_small_icon',
+ pressAction: {id: 'default'},
+ },
+ });
+ }
+ alertedVersions.add(version);
+ } catch (error) {
+ console.warn('Unable to show update alert:', error);
+ } finally {
+ alertInFlight = false;
+ }
+};
+
+export const setUpdateFocused = value => {
+ focused = value;
+};
+
+export const checkForUpdates = ({force = false} = {}) => {
+ if (inFlight) {
+ return inFlight;
+ }
+ if (!force && Date.now() < nextCheckAt) {
+ return Promise.resolve(status);
+ }
+ inFlight = (async () => {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), 10000);
+ try {
+ const response = await fetch(VERSION_URL, {signal: controller.signal});
+ if (!response.ok) {
+ throw new Error(`Update check HTTP ${response.status}`);
+ }
+ const manifest = await response.json();
+ const newer = isNewerVersion(manifest.android, APP_VERSION);
+ status = [newer, manifest.android.trim()];
+ nextCheckAt = Date.now() + UPDATE_CHECK_INTERVAL;
+ listeners.forEach(listener => listener(status));
+ } catch (error) {
+ // Preserve the last good result when offline or the manifest is invalid.
+ nextCheckAt = Date.now() + RETRY_INTERVAL;
+ } finally {
+ clearTimeout(timer);
+ }
+ await presentUpdate();
+ return status;
+ })().finally(() => { inFlight = null; });
+ return inFlight;
+};
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js
index 24b7e640..dab92717 100644
--- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js
+++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js
@@ -102,6 +102,27 @@ let CSRF_TOKEN = null;
let MAX_MESSAGE_SIZE = null; // in MiB
let CSRF_HEADER_NAME = "X-CSRF-TOKEN";
+function compareReleaseVersions(latest, installed) {
+ const parse = (value) => {
+ if (typeof value !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(value.trim())) {
+ throw new Error("Invalid release version");
+ }
+ const parts = value.trim().split(".").map(Number);
+ if (!parts.every(Number.isSafeInteger)) {
+ throw new Error("Invalid release version");
+ }
+ return parts;
+ };
+ const remote = parse(latest);
+ const current = parse(installed);
+ for (let i = 0; i < remote.length; i++) {
+ if (remote[i] !== current[i]) {
+ return remote[i] > current[i] ? 1 : -1;
+ }
+ }
+ return 0;
+}
+
/* -----------------------------------------
============ ON DOCUMENT READY =========
----------------------------------------- */
@@ -138,15 +159,10 @@ $(function () {
$(SELECTORS.serverVersion).text(`Version v${serverVersion.version}`);
fetchLatestServerVersion()
.then((latestServerVersion) => {
- if (
- typeof latestServerVersion.server !== "string" ||
- !latestServerVersion.server.trim()
- ) {
- return;
- }
- if (serverVersion.version === latestServerVersion.server) {
+ const comparison = compareReleaseVersions(latestServerVersion.server, serverVersion.version);
+ if (comparison === 0) {
$(SELECTORS.serverVersion).text(`Version v${serverVersion.version} (latest)`);
- } else if (userData.role === "ADMIN") {
+ } else if (comparison > 0 && userData.role === "ADMIN") {
$(SELECTORS.updateLink).text(
`đ Update (${serverVersion.version} â ${latestServerVersion.server})`
);
From 6a986f5cc16b0f312036bf3e9665ef9f3cc0f91c Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Fri, 23 Oct 2026 00:22:29 -0500
Subject: [PATCH 22/28] docs: expand server API guide for alternative client
development
Document authentication, HTTP endpoints, STOMP/WebRTC protocols, encryption,
payload formats, fragmentation, and recovery. Add examples, test vectors,
and interoperability checks, and update the README link.
---
ClipCascade_Server/API.md | 1138 +++++++++++++++++++++++++++++++++++++
README.md | 7 +
2 files changed, 1145 insertions(+)
create mode 100644 ClipCascade_Server/API.md
diff --git a/ClipCascade_Server/API.md b/ClipCascade_Server/API.md
new file mode 100644
index 00000000..f948eab0
--- /dev/null
+++ b/ClipCascade_Server/API.md
@@ -0,0 +1,1138 @@
+# ClipCascade server API and alternative-client implementation guide
+
+This reference describes the HTTP endpoints and WebSocket protocols implemented by
+the ClipCascade server in this repository. Paths are relative to your server URL
+(for example, `http://localhost:8080`). Use HTTPS/WSS when accessing a remote server.
+There is no `/api` prefix.
+
+This document describes the **checked-in implementation**, including the desktop
+and mobile payload conventions required to communicate with existing clients.
+The server version constant in this tree is `3.1.0`; deployed builds may differ.
+There is no separately versioned API, capability-negotiation endpoint, or generated
+OpenAPI contract. Probe the running server instead of assuming its mode or limits.
+
+## Contents
+
+- [Client implementation roadmap](#client-implementation-roadmap)
+- [Authentication and CSRF](#authentication-and-csrf)
+- [Response conventions](#response-conventions)
+- [Public HTTP endpoints](#public-http-endpoints)
+- [Authenticated HTTP endpoints](#authenticated-http-endpoints)
+- [Admin HTTP endpoints](#admin-http-endpoints)
+- [Clipboard WebSocket protocols](#clipboard-websocket-protocols)
+- [Clipboard payload encoding and encryption](#clipboard-payload-encoding-and-encryption)
+- [Size limits and deployment configuration](#size-limits-and-deployment-configuration)
+- [Recovery and troubleshooting](#recovery-and-troubleshooting)
+- [Interoperability acceptance checklist](#interoperability-acceptance-checklist)
+- [Implementation sources](#implementation-sources)
+
+## Client implementation roadmap
+
+1. Collect the server base URL, exact username, and original password. To work
+ with official clients, also collect matching encryption settings: enabled
+ (default `true`), PBKDF2 iterations (default `664937`), and extra salt (default
+ empty string). The server does not supply these encryption settings.
+2. Obtain a pre-login CSRF token and cookie, submit the password digest, retain
+ the post-login cookies, and confirm identity with `/whoami`.
+3. Fetch `/csrf-token`, `/server-mode`, `/server-version`, and `/max-size`.
+ In P2P mode, also fetch `/stun-url`.
+4. Establish the selected authenticated WebSocket transport. P2S uses STOMP;
+ P2P uses JSON signaling followed by WebRTC data channels.
+5. Serialize text/images/files, optionally encrypt the resulting string, and
+ wrap it in the clipboard envelope. Fragment only for P2P data channels.
+6. Receive, reassemble where needed, decrypt, decode, and apply the clipboard
+ update. Suppress local clipboard events caused by received updates and suppress
+ your own P2S echo.
+7. Handle disconnects, expired sessions, peer changes, and explicit logout.
+ Reconnect transports and subscriptions; there is no offline replay.
+
+| Property | P2S | P2P |
+| --- | --- | --- |
+| Default server mode | Yes | Opt-in |
+| Socket endpoint | `/clipsocket` | `/p2psignaling` |
+| Socket application protocol | STOMP | JSON signaling |
+| Clipboard transport | Server relays STOMP messages | WebRTC `cliptext` data channels |
+| Recipient selection | All subscribed sessions of the authenticated username | Client sends to each connected peer of that username |
+| Sender receives own update | Yes, when subscribed | Client does not create a connection to itself |
+| Server sees clipboard envelope | Yes; payload may be encrypted | No; sees signaling |
+| Clipboard persistence / history / replay | None | None |
+
+A device is a connection, not a registered resource. There are no device tokens,
+HTTP clipboard upload/download routes, room-management routes, delivery-status
+routes, or password-reset/email-verification flows in this API. The `email` and
+`otp` admin record fields do not imply such flows exist.
+
+## Authentication and CSRF
+
+The server uses Spring Security form login and session cookies. It does not
+provide API keys, bearer tokens, or HTTP Basic authentication. Preserve cookies
+across requests, including the session cookie returned after login; the cookie
+name is normally `JSESSIONID`. The optional JDBC session store explicitly uses
+`JSESSIONID` too. Treat the cookie value as opaque and honor its path/domain and
+Secure attributes; never decode or invent it.
+
+1. `GET /login` and retain the cookies. Read the hidden `_csrf` input from the HTML.
+2. `POST /login` with form fields `username`, `password`, and `_csrf`, using the
+ same cookies. The password must be the **lowercase hexadecimal SHA3-512 digest
+ of the UTF-8 password**, matching the official clients. Do not use Keccak-512.
+3. Successful login redirects to `/`; failed login redirects to `/login?error`.
+ A redirect or an HTML response alone does not prove authentication succeeded.
+4. Verify the session with `GET /whoami`. Fetch `GET /csrf-token` after login to
+ obtain the current token for subsequent mutations.
+
+`POST`, `PUT`, and `DELETE` requests require a CSRF token, including login,
+signup, and logout. For authenticated JSON requests, send the token in the header
+named by `headerName` in `/csrf-token` (normally `X-CSRF-TOKEN`). Form requests can
+send it using `parameterName` (normally `_csrf`). Fetch a new token after signing
+in again. `/csrf-token` itself requires authentication; use the login/signup HTML
+to obtain an initial token.
+
+Example using Python with the `requests` package installed:
+
+```python
+import getpass
+import hashlib
+from html.parser import HTMLParser
+
+import requests
+
+
+class CsrfParser(HTMLParser):
+ token = None
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ if tag == "input" and attrs.get("name") == "_csrf":
+ self.token = attrs.get("value")
+
+
+base = "http://localhost:8080"
+session = requests.Session()
+login_page = session.get(base + "/login", timeout=15)
+login_page.raise_for_status()
+parser = CsrfParser()
+parser.feed(login_page.text)
+if not parser.token:
+ raise RuntimeError("Login page did not provide a CSRF token")
+
+username = input("Username: ")
+raw_password = getpass.getpass("Password: ")
+password = hashlib.sha3_512(raw_password.encode("utf-8")).hexdigest()
+response = session.post(
+ base + "/login",
+ data={"username": username, "password": password, "_csrf": parser.token},
+ allow_redirects=False,
+ timeout=15,
+)
+from urllib.parse import urlsplit
+
+if response.status_code != 302 or urlsplit(response.headers.get("Location", "")).path != "/":
+ raise RuntimeError("Login failed: " + str(response.status_code))
+
+identity = session.get(base + "/whoami", allow_redirects=False, timeout=15)
+identity.raise_for_status()
+print(identity.json()) # {"username": "...", "role": "USER"} or role "ADMIN"
+
+response = session.get(base + "/csrf-token", timeout=15)
+response.raise_for_status()
+csrf = response.json()
+session.headers[csrf["headerName"]] = csrf["token"]
+
+# Reuse session for authenticated requests, including JSON mutations:
+# session.put(base + "/update-password", json={"newPassword": new_digest}, timeout=15)
+```
+
+Password fields in signup, registration, and password updates use the same
+SHA3-512 digest convention. The server applies BCrypt to the submitted value for
+storage. Usernames must be nonblank and cannot start or end with spaces. Roles
+are uppercase `USER` and `ADMIN`; admin registration permits only `USER`.
+
+### Credential and session lifecycle
+
+The digest is 128 lowercase hexadecimal characters. SHA3-512 and the clipboard
+PBKDF2 key are separate derivations: **keep the original password for encryption**.
+The server BCrypt-hashes the submitted credential; its validator only checks
+that a password is nonempty, not that it has the correct digest shape. Submitting
+plaintext during registration can therefore create an account incompatible with
+the official login convention. The login digest is itself a reusable credential;
+protect it and the session cookie as you would a password.
+
+Usernames are used as supplied for login and key derivation; do not silently
+lowercase or normalize them. Registration checks existing names case-insensitively,
+while authentication looks up the supplied ID. Use the exact stored spelling.
+There is no application-level maximum-length contract beyond database/framework
+constraints. Username and password changes require deriving a new clipboard key
+on every client because both inputs participate in key derivation.
+
+- Multiple concurrent login sessions are allowed (no configured maximum).
+- `POST /logout` ends the current HTTP session. Explicitly close your socket and
+ P2P peer connections as part of logout; this route does not call the application's
+ all-session WebSocket revocation service.
+- `DELETE /logout-session` revokes every session for the current username, not
+ merely the calling device. Admin per-user/global logout does the analogous
+ operation. Account password/status/name changes and deletion also revoke the
+ affected user's sessions and close tracked local sockets with code `1008`,
+ reason `Session revoked`.
+- Revoking signaling does not itself terminate an already-established direct
+ WebRTC channel. A client must close its data channels and peer connections when
+ its session is revoked or it logs out.
+- Default HTTP inactivity timeout is `525960m`, configurable by the operator.
+ Do not assume a live socket refreshes HTTP session activity. Check
+ `/validate-session` when resuming or troubleshooting a connection.
+- With session persistence disabled (default), a server restart loses in-memory
+ sessions. JDBC persistence can retain unexpired sessions across restart; sockets
+ always need reconnecting. Disabling persistence clears its stored sessions.
+
+### Signup sequence
+
+Fetch `/signup` for the CSRF token, then `/captcha` using the same cookie jar.
+Display the PNG to the user and submit the answer with the username, password
+**digest**, and CSRF token as URL-encoded form data. The captcha is 200 Ã 50 pixels
+with 5â6 alphanumeric characters, compared case-insensitively. Fetching another
+captcha replaces the previous answer. Validation consumes the answer even on a
+failed attempt, so fetch a fresh captcha before retrying signup. Registration
+success is plain text and does not create an authenticated session: log in next.
+Duplicate usernames, invalid fields, and the operator's account-count limit can
+all cause registration failure. There is no public signup-enabled JSON endpoint.
+
+### Browser clients and origins
+
+A same-origin browser client can use its cookie jar automatically for WebSockets
+and same-origin `fetch`. JavaScript cannot set a `Cookie` header on a browser
+WebSocket. Native clients must copy the applicable authenticated cookies into the
+HTTP upgrade request. Do not put credentials in a WebSocket URL or STOMP body.
+
+`CC_ALLOWED_ORIGINS` controls WebSocket origin checks; it does not enable
+cross-origin HTTP CORS. This source has no general HTTP CORS configuration. A
+separately hosted browser client therefore needs deployment support for its HTTP
+requests and cookies, or a same-origin reverse proxy. Persistent-session cookies
+use HttpOnly and SameSite=Lax; Secure follows whether the request is secure.
+Configure TLS/proxy forwarding consistently and retain certificate verification.
+Routes below are application-root paths; a proxy path prefix requires appropriate
+rewrites, including redirects (login success explicitly redirects to `/`).
+
+## Response conventions
+
+Successful data requests return JSON objects/arrays or plain text, as listed
+below. Successful mutations generally return `200` with a plain-text message,
+such as `Password updated successfully`; there is no common JSON response wrapper.
+
+Validation failures and exceptions handled by `ResponseEntityUtil` return `400`
+with plain-text error messages. Most admin role checks also return `400 Forbidden`.
+The two admin session logout endpoints explicitly return `403 Forbidden` for a
+non-admin. Missing/invalid CSRF tokens produce `403` through Spring Security.
+Unauthenticated requests generally redirect to `/login`; expired sessions can
+redirect to `/login?expired`. Clients should inspect redirects and content types
+before parsing JSON. Malformed request bodies or unhandled exceptions may produce
+Spring's error responses rather than these plain-text messages.
+
+## Public HTTP endpoints
+
+These routes do not require authentication. Public mutations still require CSRF.
+
+| Method | Path | Input / response |
+| --- | --- | --- |
+| GET | `/health` | Plain text `OK`. |
+| GET | `/ping` | Plain text `pong`. |
+| GET | `/login` | Login HTML with a CSRF form token. |
+| POST | `/login` | Form fields `username`, `password`, `_csrf`; redirects as described above. |
+| GET | `/signup` | Signup HTML if enabled; otherwise redirects to `/`. |
+| POST | `/signup` | Form fields `username`, `password`, `captchaInput`, `_csrf`. Returns `User registered successfully` or `400` (for example, `Captcha validation failed` or `Signup is disabled`). Does not log the user in. |
+| GET | `/captcha` | PNG captcha when signup is enabled; keep its session cookies for signup. Returns an empty body when signup is disabled. |
+| GET | `/logout` | Logout confirmation HTML; does not itself perform logout. |
+| POST | `/logout` | CSRF-protected session logout; redirects to `/login?logout`. |
+| GET | `/help` | Redirects to the configured project help URL. |
+| GET | `/donate` | Redirects to the donation URL when enabled, otherwise `/`. |
+
+Static assets under `/assets/**` are public. `GET /` serves the authenticated
+home UI (static `index.html`); it requires a session.
+
+## Authenticated HTTP endpoints
+
+All routes here require a logged-in user, including the configuration endpoints.
+
+| Method | Path | Input / response |
+| --- | --- | --- |
+| GET | `/whoami` | `{"username":"alice","role":"USER"}`. |
+| GET | `/validate-session` | Plain text `OK`. |
+| GET | `/csrf-token` | JSON containing `token`, `headerName`, and `parameterName`. |
+| GET | `/server-mode` | `{"mode":"P2P"}` or `{"mode":"P2S"}`. |
+| GET | `/stun-url` | `{"url":"..."}`; empty string in P2S mode. |
+| GET | `/max-size` | `{"maxsize":1048576}`; configured message size in bytes (example value). |
+| GET | `/max-message-size` | `{"maxmessagesize":1.0}`; configured message size in MiB (example value). |
+| GET | `/donation-status` | `{"enabled":true}` or `{"enabled":false}`. |
+| GET | `/server-version` | `{"version":"..."}` for the running server. |
+| GET | `/latest-server-version` | JSON fetched from the project's version URL; fields follow that upstream document. Can return `400` if fetching/parsing fails. |
+| PUT | `/update-password` | JSON `{"newPassword":""}`. Updates the current user's password and expires their sessions. |
+| DELETE | `/logout-session` | No body. Logs the current user out of all active sessions; returns a plain-text status. |
+
+## Admin HTTP endpoints
+
+All `/admin/*` routes below require an authenticated `ADMIN`. Mutations use
+`Content-Type: application/json` when a JSON body is listed, plus a CSRF header.
+
+| Method | Path | Input / response |
+| --- | --- | --- |
+| GET | `/admin/advance` | HTML showing server configuration; a non-admin is redirected to `/`. |
+| GET | `/admin/server-version` | Same response as `/server-version`. |
+| GET | `/admin/latest-server-version` | Same response as `/latest-server-version`. |
+| GET | `/admin/server-time` | Plain text `Epoch time: `. |
+| GET | `/admin/websocket-stats` | Plain-text statistics for the active WebSocket mode. |
+| GET | `/admin/online-users` | JSON map of usernames to live connection counts, e.g. `{"alice":2}`. |
+| GET | `/admin/all-users` | JSON array of user records, including the admin. |
+| GET | `/admin/users` | JSON array of `USER` records. |
+| GET | `/admin/user-details` | JSON array of `UserInfo` records. |
+| GET | `/admin/bfa-snapshot` | JSON brute-force tracker map keyed by username; each value contains `ipAccessDetails`, `uniqueIpSet`, and `lockTimeout`. |
+| GET | `/admin/bfa-snapshot-file` | Serialized tracker JSON as a byte response. A non-admin receives an empty body. |
+| GET | `/admin/users/page` | Paginated user records; parameters below. |
+| GET | `/admin/user-details/page` | Paginated user details, with an additional `enabled` field. |
+| GET | `/admin/online-users/page` | Paginated records with `username` and `connections`. |
+| GET | `/admin/bfa-snapshot/page` | Paginated tracker rows, one per username/source IP. |
+| POST | `/admin/register-user` | JSON `{"username":"alice","password":"","role":"USER","enabled":true}`. `enabled` defaults to `true` if omitted. |
+| PUT | `/admin/toggle-user-status` | JSON `{"username":"alice","enabled":"false"}` (string value). Expires the target user's sessions. |
+| PUT | `/admin/update-user-password` | JSON `{"username":"alice","newPassword":""}`. Expires the target user's sessions. |
+| PUT | `/admin/update-username` | JSON `{"oldUsername":"alice","newUsername":"alice2"}`. Expires sessions for the old username. |
+| PUT | `/admin/unlock-user` | JSON `{"username":"alice"}`; removes the user's brute-force tracker entry. |
+| DELETE | `/admin/delete-user` | JSON `{"username":"alice"}`. Expires sessions; the admin cannot be deleted. |
+| DELETE | `/admin/logout-user-session` | JSON `{"username":"alice"}`. Logs out all sessions for that user. |
+| DELETE | `/admin/logout-all-sessions` | No body. Logs out every user's sessions, including the admin's. |
+
+User records contain `username`, `password` (returned as `null`), `role`, and
+`enabled`. `UserInfo` fields are `username`, `markedForDeletion`, `firstSignup`,
+`lastLogin`, `firstSignupIp`, `lastLoginIp`, `failedLoginAttempts`, `lockoutTime`,
+`passwordChangedAt`, `email`, `otp`, `phone`, `firstName`, `lastName`, and `notes`.
+Fields may be null. Paginated brute-force rows contain `username`, `sourceIp`,
+`attempts`, `lockCount`, `lockTimeout`, `uniqueIpCount`, and `userLockTimeout`.
+
+### Admin record schemas and operation results
+
+`/admin/users` and `/admin/users/page` include only `USER` accounts;
+`/admin/all-users` also includes the admin. User-info listings can contain records
+marked for deletion whose user account no longer exists. There is one admin
+account enforced at server startup; registration cannot create a second admin.
+
+Example user record (the `password` field is deliberately cleared in listings):
+
+```json
+{"username":"alice","password":null,"role":"USER","enabled":true}
+```
+
+| User-info field | JSON type / interpretation |
+| --- | --- |
+| `username` | String account identifier |
+| `markedForDeletion` | Boolean; retained record scheduled for startup purge |
+| `firstSignup`, `lastLogin`, `passwordChangedAt` | Integer Unix epoch **seconds**, or null |
+| `firstSignupIp`, `lastLoginIp` | String, or null |
+| `failedLoginAttempts` | Integer login-history value; not the live brute-force tracker |
+| `lockoutTime` | Human-readable duration string, or null; not an expiry timestamp |
+| `email`, `otp`, `phone`, `firstName`, `lastName`, `notes` | String, or null; no public edit endpoints for these fields |
+| `enabled` | Only in paginated user-details: boolean or null if no matching account |
+
+Paginated user-details are **flat** objects, not `{info: {...}, enabled: ...}`.
+The live brute-force snapshot is a map; its nested shape is:
+
+```json
+{
+ "alice":{
+ "ipAccessDetails":{
+ "192.0.2.10":{"attempts":2,"lockCount":0,"lockTimeout":0}
+ },
+ "uniqueIpSet":["192.0.2.10"],
+ "lockTimeout":0
+ }
+}
+```
+
+Here both `lockTimeout` fields are integer Unix epoch **seconds**; zero is the
+reset epoch, not an indefinite lock. The outer timeout is user-wide; the nested
+one is per source IP. `uniqueIpSet` serializes as an unordered array.
+The paginated form flattens each username/IP into a row:
+
+```json
+{"username":"alice","sourceIp":"192.0.2.10","attempts":2,"lockCount":0,"lockTimeout":0,"uniqueIpCount":1,"userLockTimeout":0}
+```
+
+`/admin/bfa-snapshot-file` contains serialized JSON bytes of the tracker; it does
+not promise a download filename or a JSON content type. `/admin/websocket-stats`
+is human-readable, mode-dependent text, not a stable machine-readable schema.
+Online-user counts describe live transport connections, not registered devices
+or open P2P data channels. In P2P, a user with two signaling connections can still
+have zero successfully negotiated data channels.
+
+Successful mutations return `200` with the following plain-text bodies:
+
+| Route | Success body |
+| --- | --- |
+| `/signup`, `/admin/register-user` | `User registered successfully` |
+| `/update-password`, `/admin/update-user-password` | `Password updated successfully` |
+| `/admin/update-username` | `Username updated successfully` |
+| `/admin/toggle-user-status` | `User enabled successfully` or `User disabled successfully` |
+| `/admin/delete-user` | `User deleted successfully` |
+| `/admin/unlock-user` | `User unlocked successfully` |
+| `/admin/logout-all-sessions` | `All users have been logged out of all active sessions.` |
+| `/logout-session`, `/admin/logout-user-session` | `User 'alice' has been logged out of all active sessions.` or `No active sessions found for username: alice` |
+
+These endpoints do not return the changed user record. Fetch the appropriate
+listing after a change if the calling session remains valid. Representative
+application failures are `400` with `Invalid user or password`, `Invalid user or
+username`, `Invalid user or operation failed`, `User already exists or invalid
+user`, `Role must be user`, `admin user cannot be deleted`, `Invalid username`,
+or `User not found`. Exact error selection depends on the operation and validation
+path; do not use string matching as a substitute for status handling.
+
+The status-update endpoint parses its string `enabled` with
+`Boolean.parseBoolean`: only case-insensitive `"true"` enables; other/missing values
+become false. Always send an explicit `"true"` or `"false"`. Registration's `enabled`
+is a JSON boolean instead. Password updates do not require an `oldPassword` field.
+Rename can also rename the admin and invalidates its sessions if it is the target.
+Deleting or renaming marks old user-info records for deletion, which can prevent
+immediate reuse of the old name until startup cleanup. There is no user-facing
+self-delete route.
+
+Example authenticated mutation, using the `session` initialized above:
+
+```python
+# Administrative example; execute only when you intend to create this account.
+# new_digest = hashlib.sha3_512(new_password.encode("utf-8")).hexdigest()
+# result = session.post(base + "/admin/register-user", json={
+# "username": "alice", "password": new_digest,
+# "role": "USER", "enabled": True,
+# }, allow_redirects=False, timeout=15)
+# print(result.status_code, result.text)
+```
+
+### Pagination, sorting, and search
+
+The four `/page` endpoints accept `page` (zero-based, default `0`), `size` (default
+`10`), `sort`, `direction`, and `search`. Search trims whitespace and matches
+case-insensitive substrings. `direction=desc` is case-insensitive; any other
+nonempty direction is ascending. Unknown sort keys leave rows unsorted.
+
+| Endpoint | Default sort / direction | Supported sort keys | Search fields |
+| --- | --- | --- | --- |
+| `/admin/users/page` | `username` / `asc` | `username`, `enabled` | Username |
+| `/admin/user-details/page` | `lastLogin` / `desc` | All `UserInfo` fields above, plus `enabled` | Username, email, first/last name, phone, signup/login IP, notes |
+| `/admin/online-users/page` | `connections` / `desc` | `username`, `connections` | Username |
+| `/admin/bfa-snapshot/page` | `username` / `asc` | All brute-force row fields above | Username, source IP |
+
+`size <= 0` returns all matching rows on page `0`. Page numbers outside the valid
+range are clamped. Even an empty result reports at least one page. Response example:
+
+```json
+{
+ "content": [{"username":"alice","connections":2}],
+ "totalElements": 1,
+ "totalPages": 1,
+ "page": 0,
+ "size": 10,
+ "sort": "connections",
+ "direction": "desc",
+ "search": null
+}
+```
+
+## Clipboard WebSocket protocols
+
+First log in over HTTP, then send the authenticated session cookie with the
+WebSocket upgrade request. Convert `http` â `ws` or `https` â `wss` and append the
+endpoint to the server base URL. Preserve path casing. Origin checks use
+`CC_ALLOWED_ORIGINS`. Only the configured mode's socket is registered. These are
+native WebSockets; there is no SockJS endpoint or HTTP long-poll fallback.
+The HTTP session supplies the principal: STOMP `login`/`passcode` headers are not
+a substitute for the cookie. The current configuration does not require a CSRF
+header inside a STOMP CONNECT frame.
+
+### P2S: STOMP over `/clipsocket`
+
+Use a STOMP-capable WebSocket library (the bundled desktop client advertises
+STOMP versions `1.0,1.1`). A connection proceeds as follows:
+
+1. Upgrade to `/clipsocket` with the session cookie.
+2. Send `CONNECT`; await `CONNECTED` before subscribing or sending.
+3. `SUBSCRIBE` with a unique subscription ID to `/user/queue/cliptext`, normally
+ `ack:auto`.
+4. `SEND` to `/app/cliptext` with `content-type:application/json` and a clipboard
+ envelope as the body.
+5. Process `MESSAGE` frames for your subscription and tolerate heartbeat newlines.
+6. On disconnect, repeat CONNECT and SUBSCRIBE; subscription IDs are local to a
+ connection. Handle `ERROR` frames without trying to parse them as clipboard JSON.
+
+Wire example (`\0` below means one NUL byte, not the two characters backslash/zero;
+headers and the blank line use LF). Replace `host` with your server hostname:
+
+```text
+CONNECT
+accept-version:1.2
+host:localhost
+heart-beat:0,20000
+
+\0
+```
+
+```text
+SUBSCRIBE
+id:clipboard-0
+destination:/user/queue/cliptext
+ack:auto
+
+\0
+```
+
+```text
+SEND
+destination:/app/cliptext
+content-type:application/json
+
+{"payload":"Hello from a client","type":"text"}\0
+```
+
+The last example is plaintext and interoperates with clients configured with
+**encryption disabled**. For the default encrypted configuration use the encoding
+section below. In real frames, there is no newline between the body and NUL.
+When `content-length` is present it counts UTF-8 **body bytes**, excluding the
+terminating NUL, not characters or the whole frame. JSON serialization must escape
+embedded newlines/control characters. A stream parser must support multiple
+frames per received chunk and frames split across chunks; use the declared length
+when present and skip heartbeat newlines outside frames.
+
+The server emits an envelope to connected, subscribed sessions of the **same
+username, including the sender**. It preserves `payload`, `type`, and `metadata`;
+missing/null `type` becomes `text`. Missing metadata can serialize as `null`.
+The routing identity comes from authentication, not a username inside the payload.
+There is no message ID, sequence number, durable acknowledgement, or delivery
+confirmation in the application envelope. `ack:auto` needs no client ACK. Do not
+build exactly-once or offline-delivery assumptions on STOMP transport receipts.
+
+The simple broker advertises `heart-beat:20000,0`. With the client's
+`heart-beat:0,20000`, expect server heartbeats during idle periods at roughly
+20-second intervals; server-required client heartbeats are disabled. If another
+pair is negotiated, respect it. External broker deployments can differ.
+Use scheduling/network tolerance when detecting missed heartbeats.
+
+### P2P: signaling over `/p2psignaling`
+
+This socket exchanges JSON text messages without STOMP or NUL terminators.
+The server creates one user room per authenticated username. Every connection
+receives a fresh UUID peer ID, even when reconnecting from the same device.
+
+```json
+{"type":"ASSIGNED_ID","peerId":""}
+```
+
+```json
+{"type":"PEER_LIST","peers":["",""]}
+```
+
+`ASSIGNED_ID` is sent to the new connection before the handler broadcasts its
+updated peer list. `PEER_LIST` includes yourself; array order is unspecified.
+Lists are full snapshots, broadcast on connection and disconnection. Exclude
+self, create connections for new peers, and dispose connections for removed peers.
+There is no explicit JOIN, LEAVE, or fetch-peers command.
+
+| Message | Sender â recipient | Required application fields |
+| --- | --- | --- |
+| `ASSIGNED_ID` | Server â new connection | `peerId`: string |
+| `PEER_LIST` | Server â every connection in the user room | `peers`: array of strings |
+| `OFFER` | Client â server â target peer | `fromPeerId`, `toPeerId`, `offer: {type:"offer", sdp:string}` |
+| `ANSWER` | Client â server â target peer | `fromPeerId`, `toPeerId`, `answer: {type:"answer", sdp:string}` |
+| `ICE_CANDIDATE` | Client â server â target peer | `fromPeerId`, `toPeerId`, `candidate: {candidate:string, sdpMid:string, sdpMLineIndex:integer}` |
+
+Example offer and corresponding answer (SDP is abbreviated, not executable):
+
+```json
+{"type":"OFFER","fromPeerId":"peer-a","toPeerId":"peer-b","offer":{"type":"offer","sdp":""}}
+```
+
+```json
+{"type":"ANSWER","fromPeerId":"peer-b","toPeerId":"peer-a","answer":{"type":"answer","sdp":""}}
+```
+
+```json
+{"type":"ICE_CANDIDATE","fromPeerId":"peer-a","toPeerId":"peer-b","candidate":{"candidate":"candidate:1 1 udp 2130706431 192.0.2.10 50000 typ host","sdpMid":"0","sdpMLineIndex":0}}
+```
+
+The server routes only the three uppercase signaling types above, finds
+`toPeerId` inside the authenticated user's room, and forwards the original JSON
+unchanged. Missing/unknown targets and unknown types are silently ignored; there
+is no error response or acknowledgement. It does **not** validate SDP or rewrite
+or authenticate the supplied `fromPeerId`. Validate incoming sender/target IDs
+against your current peer list and connection state. Malformed JSON can fail the
+handler rather than producing a structured protocol error. Use text frames;
+the handler also attempts to interpret binary frames as UTF-8 JSON.
+
+#### WebRTC negotiation compatible with official clients
+
+For each remote peer, maintain one `RTCPeerConnection`. Use the URL returned by
+`/stun-url` as an ICE server URL when nonempty. The discovery API provides one
+URL, not TURN credentials or a list of TURN servers. NAT/firewall conditions can
+prevent direct connectivity; the server has no automatic TURN relay or P2S
+fallback within a P2P deployment.
+
+The official clients break offer collisions by comparing peer IDs
+lexicographically: **the smaller peer ID initiates the offer**.
+
+1. The initiator creates an ordinary reliable, ordered data channel named
+ **`cliptext`**, then creates an offer and sets its local description.
+2. Send the local SDP as `OFFER` to the larger peer ID. The answering client
+ applies it as its remote description, creates and sets its local answer, and
+ sends `ANSWER` back. The initiator applies that remote answer.
+3. Accept the remote channel through the data-channel event on the answerer;
+ do not create an extra channel there. Send nonempty ICE candidates through
+ `ICE_CANDIDATE`. Do not send a null end-of-candidates marker to the desktop
+ handler, which expects a candidate string. SDP can also contain gathered
+ candidates (the desktop implementation sends its local description after
+ setting it).
+4. Queue early incoming candidates until the appropriate peer connection and
+ remote description are ready. Apply only candidates/answers belonging to the
+ current negotiation; stale messages can arrive after peer churn.
+5. Wait for the data channel's `open` state before sending clipboard envelopes.
+ Broadcast to each open remote channel. The server never relays these envelopes.
+
+For recovery, official clients can replace a failed connection with a new offer;
+this is a fresh negotiation, not necessarily an ICE-restart update on the old
+connection. Close and replace stale peer state, preserve the smaller-ID initiator
+rule, and avoid competing offers. On signaling reconnect, discard the old peer
+ID, peer connections, and fragment buffers, then rebuild from the new assignment.
+
+#### Data-channel heartbeat and fragmentation
+
+Messages on `cliptext` are JSON strings. Ignore the following control message
+before looking for `payload`, `type`, or `metadata`:
+
+```json
+{"_cc_keepalive":true}
+```
+
+Official clients send this roughly every 20 seconds on idle/live channels. It is
+not a clipboard update, does not require a reply, and does not replace WebSocket
+ping/pong on the signaling connection.
+
+The clipboard envelope is the same as P2S, with fragmentation metadata:
+
+```json
+{
+ "payload":"Hello",
+ "type":"text",
+ "metadata":{
+ "id":"11111111-2222-4333-8444-555555555555",
+ "isFragmented":false,
+ "index":0,
+ "totalFragments":1,
+ "combinedRawPayloadSizeInBytes":5
+ }
+}
+```
+
+| Metadata field | Type | Meaning |
+| --- | --- | --- |
+| `id` | string | Fresh transfer UUID; identical across its fragments and recipients |
+| `isFragmented` | boolean | Whether more than one fragment was generated |
+| `index` | integer | Zero-based fragment position |
+| `totalFragments` | integer | Total fragment count (one for an unfragmented message) |
+| `combinedRawPayloadSizeInBytes` | integer | UTF-8 size of the complete serialized plaintext payload **before encryption**, not the enclosing envelope or original file byte count |
+
+Sender algorithm:
+
+1. Serialize the content into the payload string described below. Record its
+ UTF-8 length for `combinedRawPayloadSizeInBytes`.
+2. If encryption is enabled, encrypt the **whole string once** and serialize the
+ nonce/ciphertext/tag object into the new payload string.
+3. Split that string into chunks of at most **15360 UTF-8 bytes** (15 KiB), without
+ splitting a Unicode character. This limit excludes the outer JSON envelope;
+ escaping can make the actual channel message larger.
+4. Send one JSON envelope per chunk, in increasing index order, with the same
+ ID/type/count/size and an incrementing `index`. Unfragmented messages still use
+ the complete metadata object. Do not send an empty `{}` metadata object to an
+ official P2P client: it expects the fields above when metadata is present.
+5. Use data-channel buffered-amount/backpressure support to avoid unbounded queued
+ sends. Finish one transfer before starting another for best interoperability.
+
+Receiver algorithm:
+
+1. Drop keepalive messages. Check types, count/index bounds, transfer identity,
+ and local byte/count limits before allocating buffers.
+2. Collect fragments by transfer ID (and sending peer in your own implementation).
+ Require consistent metadata and join payload strings in index order only when
+ all parts exist. Then parse/decrypt the combined encrypted payload, if enabled.
+3. Decode the plaintext according to `type`. Never decrypt individual fragments
+ or JSON-parse a partial encrypted payload string.
+4. Discard abandoned buffers on disconnect or timeout; desktop expires partial
+ buffers after 60 seconds without fragment activity. Bound actual accumulated
+ bytes as well as the sender-declared size.
+
+Official clients also accept an unfragmented envelope with missing/null metadata.
+For compatibility, send ordered fragments: their receive logic expects completion
+when the last-index fragment arrives and can discard incomplete/interleaved
+transfers. A newly copied/received clipboard value can cancel in-flight work;
+there is no fragment retry, acknowledgement, resume, or transfer-cancel message.
+With encryption disabled, the official fragmenter emits no fragments for an empty
+string, so do not depend on an empty P2P payload to clear other clients' clipboards.
+
+#### Signaling liveness and limits
+
+The server periodically sends RFC 6455 **ping control frames** every 40 seconds.
+The client must respond with **pong control frames** (browser stacks handle this
+internally). The handler checks for a recorded pong older than 120 seconds and
+closes the socket with code `1001` at a heartbeat check. Ordinary JSON traffic,
+a newline, or a data-channel keepalive does not update this pong timestamp.
+
+P2P connections exceeding the configured global/per-user limits close with
+`1008` (policy violation); a missing authenticated principal also closes this way.
+The optional per-connection signaling limiter is a token bucket: capacity and
+refill per second both equal `CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC`. Values `<= 0`
+disable it. Excess nonempty messages are silently dropped, with no retry hint.
+Avoid rapid reconnect/offer loops and bursts of redundant candidates.
+
+## Clipboard payload encoding and encryption
+
+These are **client interoperability conventions**, not server-side content
+validation. The P2S server treats the payload as a string and does not encrypt,
+decrypt, inspect image bytes, or save uploaded files. P2P signaling does not see
+clipboard content at all.
+
+### Common envelope
+
+| Field | Type | Contract |
+| --- | --- | --- |
+| `payload` | string | Serialized plaintext content or a serialized encrypted object; never a nested object |
+| `type` | string | Official values are lowercase `text`, `image`, `files`; send explicitly |
+| `metadata` | object or null, optional | P2P fragmentation fields above; passed through by P2S |
+
+Do not infer encryption from the type: the type stays `text`, `image`, or `files`
+even when encrypted. There is no encryption flag, algorithm version, key ID,
+compression field, timestamp, MIME field, or sender-device ID in the standard
+envelope. Unknown types/custom metadata have no official client behavior.
+
+### Plaintext content formats
+
+| Type | Plaintext payload string | Example / notes |
+| --- | --- | --- |
+| `text` | Original text | No Base64; preserve Unicode and line breaks |
+| `image` | Standard Base64 of encoded image file bytes | No `data:image/...;base64,` prefix. Desktop preserves an existing image format or uses PNG; use PNG for broad compatibility. Not raw pixel bytes. |
+| `files` | JSON string mapping base filenames to standard Base64 file contents | No ZIP, multipart upload, path hierarchy, or download URL |
+
+Plaintext file envelope containing `hello.txt` with five bytes `Hello`:
+
+```json
+{"payload":"{\"hello.txt\":\"SGVsbG8=\"}","type":"files"}
+```
+
+Parse the outer JSON first, then parse `payload` as JSON for `files`; Base64-decode
+each value separately. File keys are names such as `hello.txt`, not paths.
+Duplicate basenames overwrite each other in the desktop sender's dictionary.
+For an alternative receiver, constrain saves to its selected download directory,
+reject path separators/traversal names, and resolve existing-file conflicts before
+writing. Clipboard peers supply these names; the server does not sanitize them.
+Feature support and clipboard permissions vary by platform; receiving data does
+not guarantee an OS clipboard update or an automatic file save.
+
+### Encryption derivation and wire representation
+
+All clients sharing data must agree on the exact username, original password,
+extra salt, iteration count, and encryption-enabled setting. Defaults in desktop
+and mobile are encryption enabled, `664937` iterations, and empty extra salt.
+These values are local configuration and are not stored/distributed by this API.
+
+```text
+loginCredential = lowercaseHex(SHA3-512(UTF8(originalPassword)))
+key = PBKDF2-HMAC-SHA256(
+ password = UTF8(originalPassword),
+ salt = UTF8(username + originalPassword + extraSalt),
+ iterations = configuredIterations,
+ outputLength = 32 bytes
+)
+```
+
+Concatenation has **no delimiter**. Do not substitute the login digest as the
+PBKDF2 password, trim the extra salt, or add Unicode normalization.
+
+Encrypt UTF-8 bytes of the serialized plaintext payload with AES-256-GCM using
+that 32-byte key and a fresh random nonce for **every** encryption. No additional
+authenticated data (AAD) is supplied. The authentication tag is 16 bytes.
+The desktop's PyCryptodome call generates a nonce (normally 16 bytes); nonce length
+is carried by the decoded field, so do not hard-code a 12-byte receive-only
+assumption. Preserve the supplied nonce length when decrypting.
+
+Encode nonce, ciphertext, and tag separately with standard padded Base64, then
+JSON-serialize this object **into a string** for the outer `payload`:
+
+```json
+{"nonce":"","ciphertext":"","tag":""}
+```
+
+If your crypto API returns ciphertext with its tag appended, split the final 16
+bytes before encoding. Reverse that step for an API expecting a combined input.
+Verify authentication before decoding or applying plaintext. Do not silently fall
+back to plaintext if decryption fails. The outer `type` and `metadata` are not
+included as GCM AAD by the official clients.
+
+Python implementation compatible with the desktop format (`pycryptodome`):
+
+```python
+import base64
+import hashlib
+import json
+from Crypto.Cipher import AES
+
+
+def derive_key(username, original_password, extra_salt="", rounds=664937):
+ return hashlib.pbkdf2_hmac(
+ "sha256",
+ original_password.encode("utf-8"),
+ (username + original_password + extra_salt).encode("utf-8"),
+ rounds,
+ dklen=32,
+ )
+
+
+def encrypt_payload(plaintext, key):
+ cipher = AES.new(key, AES.MODE_GCM) # Fresh random nonce each call.
+ ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8"))
+ return json.dumps({
+ "nonce": base64.b64encode(cipher.nonce).decode("ascii"),
+ "ciphertext": base64.b64encode(ciphertext).decode("ascii"),
+ "tag": base64.b64encode(tag).decode("ascii"),
+ })
+
+
+def decrypt_payload(payload, key):
+ obj = json.loads(payload)
+ parts = {name: base64.b64decode(obj[name], validate=True)
+ for name in ("nonce", "ciphertext", "tag")}
+ cipher = AES.new(key, AES.MODE_GCM, nonce=parts["nonce"])
+ return cipher.decrypt_and_verify(parts["ciphertext"], parts["tag"]).decode("utf-8")
+
+
+# Reuse username and raw_password from the login example.
+# key = derive_key(username, raw_password)
+# body = json.dumps({"payload": encrypt_payload("Hello", key), "type": "text"})
+```
+
+The complete transformation order is:
+
+```text
+Send: content â serialize to string â encrypt if enabled â fragment if P2P
+ â wrap each payload string in an envelope â JSON â transport
+Receive: transport â JSON envelope â reassemble if P2P â decrypt if enabled
+ â decode text/image/files â update clipboard
+```
+
+### Reproducible encryption test vector
+
+Use this fixed nonce **only in a test**; production encryption must generate a
+fresh nonce. These values let a client in another language check the two password
+derivations and AES-GCM encoding independently of a running server.
+
+| Input | Value |
+| --- | --- |
+| Username | `alice` |
+| Original password | `correct horse battery staple` |
+| Extra salt | Empty string |
+| Iterations | `664937` |
+| Plaintext | `Hello đ` (UTF-8, no trailing newline) |
+| Nonce, hexadecimal | `000102030405060708090a0b0c0d0e0f` |
+
+Expected login SHA3-512 digest:
+
+```text
+4b65d7b7acc886f9add07db3a5d42bf0032fe0109a1fd56f623c7093e8a59689f9246918a4f388034ddf393231eaba0742b3dc1840e4556270a729ce56098f35
+```
+
+Expected PBKDF2 key, hexadecimal (32 bytes):
+
+```text
+c6ffb459a9770f48246286649e2a7c5ed604796c0bb5319fb9064bfacb2595f5
+```
+
+Expected inner encryption object (JSON key order/whitespace may differ):
+
+```json
+{
+ "nonce": "AAECAwQFBgcICQoLDA0ODw==",
+ "ciphertext": "IKDCfNQa/57thQ==",
+ "tag": "AaeI3kpRaOCTKz8b/Pusmw=="
+}
+```
+
+Serialize this object into a string for `payload`; do not send it as an object.
+
+### Echo and clipboard-loop suppression
+
+The server has no deduplication or conflict resolution. Official clients track a
+hash of the most recent serialized plaintext payload before send and before
+applying received content. Desktop uses xxHash64; mobile uses xxHash32. These hashes
+are local and are not sent, so an alternative client need not use the same hash
+function. Compare content before encryption: random nonces make ciphertext change
+on every encryption. Update your local suppression state before writing the OS
+clipboard and avoid immediately retransmitting the received value.
+
+### Minimal authenticated P2S send/receive example
+
+Install `requests`, `websocket-client`, and `pycryptodome` in your own Python
+virtual environment. Run the login example first, then the encryption helper
+block, then this block in the same script. It sends one encrypted text update
+and prints the first received text update (usually its own echo). This is a
+protocol smoke test, not an OS clipboard monitor. All participating clients must
+use the same encryption settings; change `cipher_enabled` and derivation parameters
+here to match them. The example rejects P2P deployments instead of speaking the
+wrong protocol.
+
+```python
+import json
+import time
+from urllib.parse import urlsplit, urlunsplit
+import websocket
+
+
+def authenticated_json(path):
+ response = session.get(base + path, allow_redirects=False, timeout=15)
+ if response.status_code != 200:
+ raise RuntimeError(f"{path}: HTTP {response.status_code}")
+ if "application/json" not in response.headers.get("Content-Type", ""):
+ raise RuntimeError(f"{path}: expected JSON; check session")
+ return response.json()
+
+
+if authenticated_json("/server-mode")["mode"] != "P2S":
+ raise RuntimeError("This example requires a P2S server")
+max_size = authenticated_json("/max-size")["maxsize"]
+cipher_enabled = True
+key = derive_key(username, raw_password) if cipher_enabled else None
+parts = urlsplit(base.rstrip("/"))
+ws_url = urlunsplit(("wss" if parts.scheme == "https" else "ws",
+ parts.netloc, parts.path + "/clipsocket", "", ""))
+# Ask requests to select cookies with the correct path/domain/Secure scope.
+upgrade = session.prepare_request(requests.Request("GET", base + "/clipsocket"))
+cookie_header = upgrade.headers.get("Cookie", "")
+ws = websocket.create_connection(
+ ws_url, cookie=cookie_header,
+ origin=f"{parts.scheme}://{parts.netloc}", timeout=15,
+)
+
+
+def send_frame(command, headers, body=""):
+ headers = {**headers, "content-length": str(len(body.encode("utf-8")))}
+ wire = command + "\n" + "\n".join(f"{k}:{v}" for k, v in headers.items())
+ ws.send(wire + "\n\n" + body + "\x00")
+
+
+def frames():
+ # Incremental parser for this example's LF-delimited STOMP 1.1 frames.
+ # A production STOMP library should also handle CRLF and header escaping.
+ buffer = b""
+ while True:
+ chunk = ws.recv()
+ if not chunk:
+ raise RuntimeError("WebSocket closed")
+ buffer += chunk.encode("utf-8") if isinstance(chunk, str) else chunk
+ while True:
+ buffer = buffer.lstrip(b"\n") # Ignore idle heartbeat bytes.
+ split = buffer.find(b"\n\n")
+ if split < 0:
+ break
+ lines = buffer[:split].decode("utf-8").split("\n")
+ headers = dict(line.split(":", 1) for line in lines[1:])
+ start = split + 2
+ if "content-length" in headers:
+ length = int(headers["content-length"])
+ if length < 0:
+ raise RuntimeError("Invalid STOMP content-length")
+ end = start + length
+ if len(buffer) <= end:
+ break
+ if buffer[end] != 0:
+ raise RuntimeError("Missing STOMP terminator")
+ else:
+ end = buffer.find(b"\x00", start)
+ if end < 0:
+ break
+ body = buffer[start:end].decode("utf-8")
+ buffer = buffer[end + 1:]
+ yield lines[0], headers, body
+
+
+try:
+ send_frame("CONNECT", {"accept-version": "1.1", "host": parts.hostname,
+ "heart-beat": "0,20000"})
+ incoming = frames()
+ command, headers, body = next(incoming)
+ if command != "CONNECTED":
+ raise RuntimeError(f"Expected CONNECTED, received {command}: {body}")
+ send_frame("SUBSCRIBE", {"id": "clipboard-0",
+ "destination": "/user/queue/cliptext", "ack": "auto"})
+ plaintext = "Hello from an alternative client đ"
+ if len(plaintext.encode("utf-8")) > max_size:
+ raise RuntimeError("Text exceeds server clipboard limit")
+ payload = encrypt_payload(plaintext, key) if cipher_enabled else plaintext
+ send_frame("SEND", {"destination": "/app/cliptext",
+ "content-type": "application/json"},
+ json.dumps({"payload": payload, "type": "text"}))
+ deadline = time.monotonic() + 30
+ while time.monotonic() < deadline:
+ ws.settimeout(max(0.1, deadline - time.monotonic()))
+ command, headers, body = next(incoming)
+ if command == "ERROR":
+ raise RuntimeError(body)
+ if command == "MESSAGE":
+ envelope = json.loads(body)
+ if envelope.get("type", "text") == "text":
+ value = envelope["payload"]
+ print(decrypt_payload(value, key) if cipher_enabled else value)
+ break
+ else:
+ raise RuntimeError("No text update received")
+finally:
+ ws.close()
+```
+
+This script closes the socket but leaves the HTTP login valid. To end that login,
+use `session.post(base + "/logout", allow_redirects=False, timeout=15)` with the
+CSRF header already set by the login example. For a continuously running client,
+use a complete STOMP library, negotiated heartbeat deadlines, bounded input
+buffers, retry/backoff, and the clipboard-loop suppression described above.
+
+## Size limits and deployment configuration
+
+`GET /max-size` reports the configured base size in bytes; default is `1048576`.
+`GET /max-message-size` reports the same size in MiB, **floored to two decimal
+places**. Use the byte value for calculations, especially for small limits.
+
+For P2S, official clients check text UTF-8 bytes, encoded image file bytes, or
+cumulative raw file bytes against the base limit before serialization. The server
+sets WebSocket text/binary buffer limits, STOMP message/buffer limits, and Jackson's
+JSON string limit to **twice the base size** to allow encoding overhead. Its STOMP
+send time limit is 200000 ms. It does not separately reject a decoded clipboard
+payload at the base limit in the message handler. JSON escaping, Base64, encryption,
+filenames, and framing can still exceed the transport ceiling. Sending near the
+limit can close a socket rather than return an HTTP `413` or structured JSON error.
+P2S has no application-level fragment reassembly; do not reuse P2P fragment envelopes
+to bypass its limit when communicating with official clients.
+
+For P2P, these base-size discovery endpoints still return configured values, but
+clipboard data bypasses the server. Official desktop switches its server clipboard
+limit to `-1` in this mode and relies on local limits. The P2S buffer configuration
+is conditional and does not configure the P2P signaling endpoint; do not infer its
+maximum SDP size from `/max-size`. Apply your own transfer/buffer limits and account
+for runtime, proxy, and WebRTC message limits.
+
+Client-relevant server settings (defaults from this tree):
+
+| Environment variable | Default | Effect |
+| --- | --- | --- |
+| `CC_PORT` | `8080` | HTTP listening port |
+| `CC_P2P_ENABLED` | `false` | Selects P2P instead of P2S |
+| `CC_ALLOWED_ORIGINS` | `*` | WebSocket allowed origin value; not HTTP CORS |
+| `CC_MAX_MESSAGE_SIZE_IN_MiB` | `1` | Base message size when byte override is not positive; nonpositive MiB falls back to 1 |
+| `CC_MAX_MESSAGE_SIZE_IN_BYTES` | `0` | Positive value overrides MiB setting |
+| `CC_P2P_STUN_URL` | `stun:stun.l.google.com:19302` | P2P ICE discovery URL |
+| `CC_MAX_WS_GLOBAL_CONNECTIONS` | `-1` | P2P global connection cap; `-1` unlimited |
+| `CC_MAX_WS_CONNECTIONS_PER_USER` | `-1` | P2P per-user connection cap; `-1` unlimited |
+| `CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC` | `-1` | P2P token bucket; nonpositive disables |
+| `CC_SESSION_TIMEOUT` | `525960m` | HTTP session inactivity timeout |
+| `CC_SESSION_PERSISTENCE_ENABLED` | `false` | Opt-in JDBC sessions, H2/PostgreSQL |
+| `CC_SIGNUP_ENABLED` | `false` | Public registration and captcha |
+| `CC_CAPTCHA_DIFFICULTY_PERCENT` | `100` | Captcha presentation difficulty, clamped to 0â100 |
+| `CC_MAX_USER_ACCOUNTS` | `-1` | Total account cap including admin; `-1` unlimited |
+| `CC_ACCOUNT_PURGE_TIMEOUT_SECONDS` | `-1` | Inactive non-admin cleanup at startup; negative disables |
+| `CC_MAX_UNIQUE_IP_ATTEMPTS` | `15` | Brute-force tracker unique-IP threshold |
+| `CC_MAX_ATTEMPTS_PER_IP` | `30` | Brute-force tracker per-IP threshold |
+| `CC_LOCK_TIMEOUT_SECONDS` | `60` | Brute-force lockout base duration |
+| `CC_LOCK_TIMEOUT_SCALING_FACTOR` | `2` | Per-IP lock duration scaling |
+| `CC_EXTERNAL_BROKER_ENABLED` | `false` | P2S external STOMP broker relay |
+| `CC_BROKER_HOST` / `CC_BROKER_PORT` | `localhost` / `61613` | Backend broker connection; clients still use `/clipsocket` |
+| `CC_BROKER_USERNAME` / `CC_BROKER_PASSWORD` | `admin` / `admin` | Server-to-broker credentials, not client HTTP credentials |
+| `CC_DONATIONS_ENABLED` | `false` | Donation link behavior |
+
+Database, logging, and BFA cache settings are operator settings documented in the
+[server configuration guide](../README.md#-server-configuration) and
+[application properties](ClipCascade_Backend/src/main/resources/application.properties).
+Clients cannot change server configuration through an API endpoint. Treat settings
+as deployment-specific; most are not exposed by the small discovery API.
+
+Brute-force failures/disabled accounts normally reach the same login error flow;
+there is no client-facing structured lockout duration or `Retry-After` contract.
+Stop automatic bad-credential retries and let the user resolve credentials or
+ask an admin to inspect/unlock the account.
+
+## Recovery and troubleshooting
+
+Reconnect with backoff and jitter. Revalidate the HTTP session after transport
+failure or sleep. If authenticated, rediscover mode/limits as needed and recreate
+the socket; if unauthenticated, obtain a new login CSRF token, authenticate, and
+replace cookies and the authenticated CSRF token. Never keep reconnecting with a
+known-revoked cookie. Deliberate logout must disable automatic reconnection.
+
+| Symptom | Checks / action |
+| --- | --- |
+| A JSON endpoint returns HTML or a redirect | Disable automatic redirects during diagnosis; check `/whoami`, cookie scope, and session expiry. A followed `200` login page is not success. |
+| Login redirects to `?error` | Check exact username, lowercase SHA3-512 of original UTF-8 password, enabled status, and lockout. Do not double-hash the digest. |
+| Mutation returns `403` | Fetch a fresh CSRF token for the same session and use its named header; admin session-logout routes also return `403` for non-admins. |
+| Admin route returns `400 Forbidden` | Verify `/whoami` reports `ADMIN`; most controller checks use `400`, not `403`. |
+| WebSocket upgrade fails | Confirm mode/path, cookie, origin, proxy Upgrade support, TLS trust, and authentication. A public `/health` success does not establish a valid login. |
+| STOMP connects but receives nothing | Wait for CONNECTED; subscribe to `/user/queue/cliptext`; send to `/app/cliptext`; compare usernames and check encoding settings. |
+| P2S disconnects only on large clipboard updates | Measure serialized UTF-8 frame size, Base64/GCM overhead, broker/proxy limits, and `/max-size`. |
+| P2P peer list appears but channel never opens | Check initiator ordering, SDP/candidate exchange, candidate timing, STUN/network reachability, and NAT constraints. Signaling presence is not channel readiness. |
+| P2P negotiation intermittently stalls | Check signaling rate-limit drops, stale peer IDs, and conflicting recovery offers. |
+| P2P disconnects after idle time | Respond to WebSocket ping with pong; data-channel traffic cannot satisfy signaling heartbeat checks. |
+| Decryption/JSON errors | Align encryption enabled, original password, username, extra salt, and iterations; check Base64 fields and ciphertext/tag splitting. |
+| Partial files or transfers never finish | Enforce ordered UTF-8-safe fragmentation, same metadata per transfer, complete reassembly before decrypt, and avoid interleaved transfers. |
+| Clipboard repeats indefinitely | Suppress local events from received content and the sender's P2S echo, using plaintext comparisons. |
+| Renamed/deleted username cannot be registered again | User-info records can reserve a name until startup purges records marked for deletion. |
+
+No catch-up endpoint exists. A reconnect can miss updates while offline; choose
+an explicit local policy for whether to send the current clipboard after reconnect
+instead of assuming the server holds the latest value.
+
+## Interoperability acceptance checklist
+
+Use two sessions on the same account, plus a separate account to check routing.
+Run against both server modes and, where relevant, against desktop and mobile.
+These are implementation acceptance checks, not claims of automated test coverage.
+
+- [ ] Login succeeds with a digest and fails with an incorrect credential; JSON
+ checks detect redirects/HTML and CSRF tokens are refreshed after re-login.
+- [ ] Signup retains its captcha session, handles consumed challenges, and logs
+ in separately after successful registration.
+- [ ] Same-account sessions exchange text; separate accounts do not receive the
+ application's user-targeted clipboard messages.
+- [ ] Unicode, emoji, multiline text, and quotes survive JSON and STOMP byte counts.
+- [ ] Encryption round-trips with official clients, including matching nondefault
+ salt/iterations; mismatched settings and altered tags fail without pasting.
+- [ ] PNG images and multiple files decode correctly; filenames and file contents
+ survive the extra JSON/Base64 layers.
+- [ ] P2S echo does not cause clipboard loops; reconnection resubscribes.
+- [ ] P2P initiator ordering works with either peer joining first; peer departure,
+ sleep/resume, and fresh IDs remove stale connections.
+- [ ] P2P payloads below/above 15360 bytes and multibyte characters at chunk
+ boundaries reassemble; keepalive messages never modify the clipboard.
+- [ ] Incomplete/invalid fragments are bounded and discarded; local size limits
+ work even when sender metadata understates the actual size.
+- [ ] Near-limit P2S data and excess P2P connections/rate limits fail gracefully.
+- [ ] Password/status/name changes and admin logout revoke sessions; clients close
+ direct channels and do not automatically reconnect using stale credentials.
+- [ ] Restart is handled with both in-memory and persistent sessions; a live HTTP
+ session never leads the client to reuse a dead socket or old subscription.
+
+## Implementation sources
+
+When changing the protocol, update this guide alongside these sources:
+
+- [HTTP routes and STOMP message handler](ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java)
+- [Authentication and access rules](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java)
+- [Session persistence and cookies](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SessionPersistenceConfiguration.java)
+- [Session revocation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java)
+- [Live socket revocation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java)
+- [User validation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java)
+- [Pagination](ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java)
+- [STOMP configuration](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java)
+- [P2P signaling handler](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java)
+- [Configuration and size calculations](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java)
+- [Official desktop HTTP client](../ClipCascade_Desktop/src/utils/request_manager.py)
+- [Desktop credential preparation](../ClipCascade_Desktop/src/core/application.py)
+- [Desktop STOMP client](../ClipCascade_Desktop/src/stomp_ws/stomp_manager.py)
+- [Desktop P2P and fragmentation](../ClipCascade_Desktop/src/p2p/p2p_manager.py)
+- [Desktop encryption](../ClipCascade_Desktop/src/utils/cipher_manager.py)
+- [Desktop image/file serialization](../ClipCascade_Desktop/src/clipboard/clipboard_manager.py)
+- [Desktop configuration defaults](../ClipCascade_Desktop/src/core/config.py)
+- [Mobile key derivation](../ClipCascade_Mobile/src/App.js)
+- [Mobile encryption, WebRTC, and fragmentation](../ClipCascade_Mobile/src/StartForegroundService.js)
diff --git a/README.md b/README.md
index 5f9d9c1e..c526c0dc 100644
--- a/README.md
+++ b/README.md
@@ -1224,6 +1224,13 @@ You can adjust these settings in the **advanced settings** section on the login
+## Server API Documentation
+
+To build an alternative client, see the [Server API and client implementation guide](ClipCascade_Server/API.md).
+It covers authentication and CSRF, HTTP endpoints, admin operations, P2S/STOMP and
+P2P/WebRTC protocols, clipboard encryption and payload formats, fragmentation,
+reconnection, and working examples with an interoperability checklist.
+
## â Reverse Proxy Setup
Below is a screenshot demonstrating how to configure a reverse proxy using **Cloudflare Tunnels**. Similar configurations can be applied with other providers as well.
From 075e36dd977b3d48d8a7b5f20c8e0a6a9623c130 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:08:55 -0500
Subject: [PATCH 23/28] fix(macos): prevent bundled OpenSSL mismatch at startup
Require prebuilt cryptography wheels to avoid shared OpenSSL conflicts
in PyInstaller bundles. Reject dynamically linked cryptography builds
during macOS packaging.
Build with --clean and disable pip's dependency cache in CI. Run a
headless self-test inside the packaged executable on Intel and Apple
Silicon before publishing, with a 5-minute timeout.
The self-test checks native dependency imports, certificate generation,
and P2P data-channel offer creation.
---
.github/workflows/README.md | 40 +++++++++++++++++--
.github/workflows/release.yml | 19 ++++++++-
.../src/ClipCascade_macos.spec | 22 +++++++++-
.../src/packaging/macos_self_test.py | 31 ++++++++++++++
ClipCascade_Desktop/src/requirements_mac.txt | 3 ++
5 files changed, 109 insertions(+), 6 deletions(-)
create mode 100644 ClipCascade_Desktop/src/packaging/macos_self_test.py
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index bf6815f9..19f9c3a0 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -200,9 +200,15 @@ dependency installation if the Python version or running architecture differs
from the required values. Tcl/Tk versions are not separately checked.
This replaces the previous Intel Python 3.11.2 pin.
-Each job also checks aiortc/PyAV imports, uses the existing PyInstaller
-specification, and verifies the packaged executable's architecture before uploading
-its ZIP. These checks do not replace testing P2P in the packaged app.
+Each job also checks aiortc/PyAV imports, builds with PyInstaller's `--clean`
+option, and verifies the packaged executable's architecture. Before uploading,
+it runs the actual bundled executable with `--self-test`, allowing up to 5
+minutes for launch and execution to accommodate macOS first-launch security
+checks. The CI step has a separate 6-minute limit. These limits do not bypass
+security checks or approve interactive prompts; a launch requiring manual
+approval still needs attention. This headless check imports the native TLS/media dependencies,
+generates a certificate, and creates a P2P data-channel offer without starting
+the GUI. These checks do not replace testing P2P between devices.
Both builds must succeed for the release to publish. When macOS is
unchanged, both ZIPs are carried over from the previous release.
@@ -212,6 +218,34 @@ prebuilt PyAV wheels. If PyAV falls back to source, its supported FFmpeg version
must match the installed libraries; installing the tools alone does not guarantee
source-build compatibility.
+`requirements_mac.txt` requires a binary wheel for `cryptography`; CI disables
+pip's cache for this installation. The official macOS wheels statically link
+OpenSSL. A dynamically linked source build can load a different `libssl.3.dylib`
+after PyInstaller rewrites library paths, causing errors such as
+`Symbol not found: _SSL_get0_group_name` at startup. The macOS spec checks the
+Rust binding with `otool -L` and rejects shared OpenSSL dependencies before
+packaging. Installing another OpenSSL on the user's Mac does not repair the
+library already embedded in the executable.
+
+For a local rebuild, start with a **new** virtual environment using the same
+python.org Python 3.12.10 interpreter as CI. From `ClipCascade_Desktop/src`:
+
+```sh
+/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 -m venv .venv-macos
+source .venv-macos/bin/activate
+python3 -m pip install --upgrade pip
+python3 -m pip install --upgrade pyinstaller pyinstaller-hooks-contrib
+python3 -m pip install --no-cache-dir -r requirements_mac.txt
+python3 -m PyInstaller --clean --noconfirm ClipCascade_macos.spec
+dist/ClipCascade.app/Contents/MacOS/ClipCascade --self-test
+```
+
+Use a new venv name if `.venv-macos` already exists. Reusing an environment with
+an installed source build can leave that build in place even with
+`--only-binary`. Run the self-test on each target architecture and test the GUI
+on the oldest macOS release you intend to support; a successful build on a newer
+GitHub runner does not establish compatibility with macOS 12.
+
Include `macos` (or `desktop` / `all`) in the first releasing PR after adding Intel
support so the new Intel ZIP is generated; older releases may not contain it.
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 23535538..cbbb1496 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -286,7 +286,7 @@ jobs:
run: |
python3 -m pip install --upgrade pip certifi
python3 -m pip install pyinstaller
- python3 -m pip install -r requirements_mac.txt
+ python3 -m pip install --no-cache-dir -r requirements_mac.txt
- name: Check aiortc before packaging
run: |
@@ -303,7 +303,7 @@ jobs:
- name: Build ClipCascade.app with PyInstaller
working-directory: ClipCascade_Desktop/src
- run: python3 -m PyInstaller ClipCascade_macos.spec --noconfirm
+ run: python3 -m PyInstaller ClipCascade_macos.spec --clean --noconfirm
- name: Verify packaged executable architecture
env:
@@ -311,6 +311,21 @@ jobs:
run: |
lipo -verify_arch "$EXPECTED_ARCH" ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade
+ - name: Test packaged native dependencies
+ timeout-minutes: 6
+ run: |
+ python3 - <<'PY'
+ import subprocess
+
+ # Allow extra time for macOS security checks on the first launch.
+ print("Starting packaged self-test; allowing up to 5 minutes for launch and checks.", flush=True)
+ subprocess.run(
+ ["ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade", "--self-test"],
+ check=True,
+ timeout=300,
+ )
+ PY
+
- name: Stage and zip (on macOS to preserve attributes)
env:
ARCHIVE_NAME: ${{ matrix.archive }}
diff --git a/ClipCascade_Desktop/src/ClipCascade_macos.spec b/ClipCascade_Desktop/src/ClipCascade_macos.spec
index 093361e4..ce5d72c6 100644
--- a/ClipCascade_Desktop/src/ClipCascade_macos.spec
+++ b/ClipCascade_Desktop/src/ClipCascade_macos.spec
@@ -1,6 +1,26 @@
# -*- mode: python ; coding: utf-8 -*-
import certifi
+import importlib.util
+import re
+import subprocess
+
+# PyInstaller rewrites dylib paths inside the bundle. A dynamically linked
+# cryptography can then resolve to Python's different OpenSSL with the same
+# filename, even when imports in the unfrozen environment succeed.
+rust_binding = importlib.util.find_spec('cryptography.hazmat.bindings._rust')
+if rust_binding is None or not rust_binding.origin:
+ raise SystemExit('cryptography Rust binding is missing; install requirements_mac.txt')
+linked_libraries = subprocess.check_output(
+ ['otool', '-L', rust_binding.origin], text=True
+)
+if re.search(r'/lib(?:ssl|crypto)[^/\s]*\.dylib', linked_libraries):
+ raise SystemExit(
+ 'cryptography links to a shared OpenSSL library, which can collide with '
+ 'the bundled Python OpenSSL. Reinstall official wheels in a fresh venv: '
+ 'python3 -m pip install --no-cache-dir -r requirements_mac.txt\n'
+ + linked_libraries
+ )
a = Analysis(
['main.py'],
@@ -10,7 +30,7 @@ a = Analysis(
hiddenimports=['tkinter'],
hookspath=[],
hooksconfig={},
- runtime_hooks=[],
+ runtime_hooks=['packaging/macos_self_test.py'],
excludes=[],
noarchive=False,
optimize=0,
diff --git a/ClipCascade_Desktop/src/packaging/macos_self_test.py b/ClipCascade_Desktop/src/packaging/macos_self_test.py
new file mode 100644
index 00000000..7442fe2a
--- /dev/null
+++ b/ClipCascade_Desktop/src/packaging/macos_self_test.py
@@ -0,0 +1,31 @@
+"""Run a headless native-dependency check before the normal frozen entry point."""
+
+import sys
+
+if sys.argv[1:] == ['--self-test']:
+ import asyncio
+ import ssl
+
+ import aiortc
+ import av
+ import cryptography
+ from aiortc import RTCCertificate, RTCPeerConnection
+ from cryptography.hazmat.backends.openssl.backend import backend
+
+ async def check_peer_connection():
+ # Exercise certificate generation and DTLS setup, beyond import checks.
+ certificate = RTCCertificate.generateCertificate()
+ assert certificate.getFingerprints(), 'No certificate fingerprints'
+ peer = RTCPeerConnection()
+ try:
+ peer.createDataChannel('packaging-self-test')
+ await peer.createOffer()
+ finally:
+ await peer.close()
+
+ asyncio.run(check_peer_connection())
+ print('Python OpenSSL:', ssl.OPENSSL_VERSION, flush=True)
+ print('cryptography:', cryptography.__version__, backend.openssl_version_text(), flush=True)
+ print('aiortc:', aiortc.__version__, 'PyAV:', av.__version__, flush=True)
+ print('ClipCascade packaged dependency self-test passed', flush=True)
+ sys.exit(0)
diff --git a/ClipCascade_Desktop/src/requirements_mac.txt b/ClipCascade_Desktop/src/requirements_mac.txt
index 26bfa42a..45c97fc5 100644
--- a/ClipCascade_Desktop/src/requirements_mac.txt
+++ b/ClipCascade_Desktop/src/requirements_mac.txt
@@ -1,3 +1,6 @@
+# Official cryptography wheels include a private, statically linked OpenSSL.
+# A source build can collide with Python's libssl.3.dylib after freezing.
+--only-binary cryptography
certifi
pyobjus==1.2.3
pasteboard==0.4.0
From 343a1ef9d66342f55dbc251018925105ed4807ef Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Fri, 23 Oct 2026 23:53:55 -0500
Subject: [PATCH 24/28] fix(linux-cli): align version row with menu border
---
ClipCascade_Desktop/src/cli/tray.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/ClipCascade_Desktop/src/cli/tray.py b/ClipCascade_Desktop/src/cli/tray.py
index fd07999a..05a46443 100644
--- a/ClipCascade_Desktop/src/cli/tray.py
+++ b/ClipCascade_Desktop/src/cli/tray.py
@@ -102,7 +102,9 @@ def display_menu(self):
for item in self.menu_items:
if isinstance(item, tuple):
width = max_width
- if "disconnect" in item[0].lower() or "logs" in item[0].lower():
+ # The version icon uses the same variation selector as Open Logs,
+ # so both rows need the extra padding to align the right border.
+ if any(label in item[0].lower() for label in ("disconnect", "logs", "version")):
if "disconnecting" in item[0].lower():
width -= 1
else:
From 7301e2ae5bf59ebce23ae273062a1aafd7629128 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Sat, 24 Oct 2026 00:08:06 -0500
Subject: [PATCH 25/28] Fix single-instance locking on Linux and macOS
- Preserve the lock file across launches and shutdowns.
- Acquire the lock before clearing or updating the stored PID.
- Close failed lock handles without affecting the running instance.
- Remove PID-based stale-file deletion and the "Run anyway" bypass.
- Report only lock contention as an already-running instance.
---
ClipCascade_Desktop/src/core/application.py | 80 ++++++---------------
1 file changed, 23 insertions(+), 57 deletions(-)
diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py
index 6dd0745e..b7d8f714 100644
--- a/ClipCascade_Desktop/src/core/application.py
+++ b/ClipCascade_Desktop/src/core/application.py
@@ -1,4 +1,5 @@
import atexit
+import errno
import logging
import signal
import sys
@@ -24,13 +25,11 @@
from cli.login import LoginForm
from cli.info import CustomDialog
from cli.tray import TaskbarPanel
- from cli.message_box import MessageBox
from cli.echo import Echo
else:
from gui.login import LoginForm
from gui.info import CustomDialog
from gui.tray import TaskbarPanel
- from gui.message_box import MessageBox
class Application:
@@ -102,67 +101,40 @@ def ensure_single_instance(self):
).mainloop()
sys.exit(1)
- # If a previous run died without cleaning up, the lock *file* may be
- # left over even though the kernel released the flock. Remove it if
- # the recorded PID is no longer alive â flock would still succeed,
- # but this keeps the file tidy and avoids confusing the user.
- self._remove_stale_lock_file_if_dead()
-
- # Create the lock file
+ # The kernel lock determines ownership, not the file's existence or PID.
+ # Keep the same file in place so all launches lock the same inode.
try:
self.create_lock_file()
- except IOError:
- run_anyway = MessageBox().askquestion(
- "ClipCascade",
- "Another instance of ClipCascade is already running. Do you want to run anyway?",
- )
- if run_anyway == "yes":
- try:
- os.remove(self.mutex_identifier)
- except OSError:
- pass
- self.create_lock_file()
- else:
- self.lock_file = None
- sys.exit(0)
+ except OSError as error:
+ if error.errno not in (errno.EAGAIN, errno.EWOULDBLOCK):
+ raise
+ CustomDialog(
+ "Another instance of ClipCascade is already running.",
+ msg_type="warning",
+ ).mainloop()
+ sys.exit(0)
# Backup cleanup paths for crashes / signals â finally in run() only
# runs on normal completion or handled exceptions.
atexit.register(self._cleanup_lock_file)
self._install_signal_handlers()
- def _remove_stale_lock_file_if_dead(self):
- if not os.path.exists(self.mutex_identifier):
- return
- try:
- with open(self.mutex_identifier, "r") as f:
- content = f.read().strip()
- if not content:
- return
- pid = int(content)
- if pid == os.getpid():
- return
- # os.kill(pid, 0) raises if the process is gone or unreachable.
- os.kill(pid, 0)
- except (ValueError, ProcessLookupError, PermissionError, OSError):
- # Stale or unreadable lock file â safe to remove.
- try:
- os.remove(self.mutex_identifier)
- except OSError:
- pass
-
def create_lock_file(self, path=None):
if path is None:
path = self.mutex_identifier
if PLATFORM == MACOS or PLATFORM.startswith(LINUX):
- self.lock_file = open(path, "w")
- fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ lock_file = open(path, "a+")
try:
- self.lock_file.write(str(os.getpid()))
- self.lock_file.flush()
- except Exception:
- pass
+ fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ lock_file.seek(0)
+ lock_file.truncate()
+ lock_file.write(str(os.getpid()))
+ lock_file.flush()
+ except BaseException:
+ lock_file.close()
+ raise
+ self.lock_file = lock_file
def _install_signal_handlers(self):
def _handler(signum, _frame):
@@ -183,19 +155,13 @@ def _cleanup_lock_file(self):
return
if self.lock_file is None:
return
- try:
- fcntl.flock(self.lock_file, fcntl.LOCK_UN)
- except Exception:
- pass
+ # Closing releases the kernel lock, including after a crash. Never unlink
+ # the file: another process may already have it open or acquire it next.
try:
self.lock_file.close()
except Exception:
pass
self.lock_file = None
- try:
- os.remove(self.mutex_identifier)
- except OSError:
- pass
def authenticate_and_connect(self):
# Attempt to connect with existing cookie
From f1ef3783b03677e3589f21e545060944afc7ea02 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Sat, 24 Oct 2026 18:11:36 -0500
Subject: [PATCH 26/28] docs: update README
---
README.md | 55 ++++++++++++++++++++++++++++++++++++++++++++++++-------
1 file changed, 48 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index c526c0dc..8a54414d 100644
--- a/README.md
+++ b/README.md
@@ -274,6 +274,7 @@ To install the ClipCascade Windows desktop application, follow these steps:
To build your own desktop executable from source (`ClipCascade_Desktop/src`):
```bash
+pip3 install pyinstaller
pip3 install -r requirements_win.txt
python3 -m PyInstaller ClipCascade_win.spec
```
@@ -347,8 +348,9 @@ To install the ClipCascade macOS desktop application, follow these steps:
To build your own desktop executable from source (`ClipCascade_Desktop/src`):
```bash
-pip3 install -r requirements_mac.txt
-python3 -m PyInstaller ClipCascade_macos.spec
+pip3 install certifi pyinstaller
+pip3 install --no-cache-dir -r requirements_mac.txt
+python3 -m PyInstaller ClipCascade_macos.spec --clean
```
[âĄī¸ Explore Advanced Details](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#%EF%B8%8F-advanced-details)
@@ -652,6 +654,11 @@ cd /path/to/clipcascade/src/ && sudo python3 main.py
+#### Web Dashboard and Administration
+
+- Web pages follow your system's light/dark theme on each page load; use the theme toggle to switch the current page's appearance.
+- The Admin Panel user list and the Advanced page's User Details, Online Users, and BFA tracker tables support searching, sorting, and pagination. Advanced tables also have resizable columns. Online Users shows active WebSocket connections in both P2S and P2P modes.
+- Administrators can revoke sessions for an individual user or use **Revoke All Sessions**. Editing or deleting an account, or changing its password, also revokes its sessions and disconnects its active WebSocket connections on this server without a restart. Deleted or renamed usernames remain reserved until the server restarts.
#### Environment Variables:
@@ -788,6 +795,8 @@ Defines the internal port where the ClipCascade server listens for connections.
Specifies the duration before user sessions expire, using minute-based formatting ([number]m).
Default:525960m (~1 year).
+
+Sessions are lost on server restart unless CC_SESSION_PERSISTENCE_ENABLED is enabled.
525960m
@@ -891,8 +900,8 @@ Specifies the username for the database connection.
Defines the password used for encrypting the user database.
Note:
-- (H2) Replace <file password> <user password> with secure values.
-- Once set, you must use the same password whenever you migrate the database.
+- (H2) Change the default password to secure values in the format <file password> <user password> before the first server start, especially when CC_SESSION_PERSISTENCE_ENABLED is enabled, since login sessions are stored in the database.
+- Keep the same value across restarts, upgrades, and migrations when reusing the database. Changing this variable alone does not update the existing database's password.
QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky
@@ -1051,6 +1060,35 @@ Defines the STOMP broker password for external message handling.
-1
+
+
+
CC_SESSION_PERSISTENCE_ENABLED
+
+ Keeps login sessions across server restarts in the existing H2 or PostgreSQL database. Requires persistent database storage (the /database volume for Docker's default H2 setup).
+
+ Sessions still follow CC_SESSION_TIMEOUT. Starting the server with persistence disabled deletes previously saved sessions.
+
+
false
+
+
+
+
+
CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC
+
+ Limits P2P signaling messages per WebSocket session per second. Short bursts up to the limit are allowed; excess messages are dropped. Applies only in P2P mode. Values of 0 or less disable the limiter.
+
+
-1
+
+
+
+
+
CC_CAPTCHA_DIFFICULTY_PERCENT
+
+ Sets CAPTCHA difficulty from 0 (easiest) to 100 (hardest). Lower values reduce visual noise and distortion while retaining a basic challenge. Values outside this range are clamped to it.
+
+
100
+
+
@@ -1059,8 +1097,8 @@ Defines the STOMP broker password for external message handling.
### đĨī¸đą Client Apps
- Logs (`clipcascade_log.log`) are stored in the installation directory on Windows and Linux, and in `/Library/Application Support/ClipCascade/` on macOS. These logs allow you to review application activity and are automatically reset each time the application is reopened, preventing indefinite growth.
- The `DATA` file stores settings and user details, enabling the app to retain this information across both restarts and updates.
-- On Linux and macOS, a `ClipCascade.lock` file is created while the program is running. This file ensures that only a single instance of ClipCascade can be opened at a time.
-- All apps include a built-in update check feature, conveniently displayed on the homepage or taskbar. This ensures you can quickly check for updates within the app, keeping you up to date with the latest enhancements and security fixes.
+- On Linux and macOS, ClipCascade holds an operating-system lock on `ClipCascade.lock` to allow only one instance at a time. The file remains after exit; the lock is released when the process exits, including after a crash.
+- Desktop and Android check for updates at startup and once a day while running, retrying failed checks after an hour. Update status remains visible in the tray/menu or Android home screen. Alerts appear once per new version each app session; Android uses a popup while the app is active or a notification in the background.
@@ -1081,8 +1119,11 @@ Use **Log in** to connect. Select **Show advanced settings** to expand additiona
Connection status notifications appear on the main login screen, outside advanced settings, on both desktop and Android. Desktop enables them by default; Android disables them by default. Encryption is also on the main login screen and is enabled by default on both platforms.
+Device preferences save as you change them, without requiring a successful login: notification settings, local clipboard size limit, image/file sharing, password-storage preference, the desktop download folder, and Android startup/periodic checks. Server address, credentials, encryption settings, and custom CA configuration are saved after successful login.
+
+- **Notify when an update is available**: Enabled by default on desktop and Android. Disable it to stop update alerts; update checks and the visible update status remain available.
- **Local clipboard size limit (bytes)**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. You can set a local size limit by specifying a value in bytes (e.g., 512 KiB = 524288 bytes) to test different thresholds suitable for your device. This local limit works alongside the server-specified limit to ensure smoother operation without crashes. For example, on Android (particularly on the Pixel 6a as of 2024), the platform limit(for text) is typically less than 1 MiB. Since the server limit cannot go below 1 MiB, setting the local limit to around 900,000 bytes on the Pixel 6a can help prevent crashes.
-- **Store Password Locally (not recommended)**: Enable this option if you frequently encounter session logouts. While the app stores session cookies for an extended period, a server restart may prompt a re-login. If re-entering the password becomes tedious, you can use this option to store your password locally for convenience.
+- **Store Password Locally (not recommended)**: Enable this option if you frequently encounter session logouts. While the app stores session cookies for an extended period, a server restart may prompt a re-login unless the server enables session persistence. If re-entering the password becomes tedious, you can use this option to store your password locally for convenience. Disabling this option immediately clears the saved password.
> Note: This option will only work if encryption is disabled, as encryption requires the raw password to generate a password hash.
- **Enable Image Sharing and Enable File Sharing**: Enabling these options allows the app to send images or files. However, the app will continue to receive images and files even if these options are disabled.
- **Show connection status notifications** (Desktop): Turn on this option to receive notifications about WebSocket disconnections and reconnections.
From 95eb35253716b8bc096b99a2a5225f7a7bc28557 Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Sat, 24 Oct 2026 18:13:36 -0500
Subject: [PATCH 27/28] fix(ci): install Python SSL certificates in macOS
builds
---
.github/workflows/release.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index cbbb1496..d1a0e39a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -246,6 +246,7 @@ jobs:
--output "$PKG"
pkgutil --check-signature "$PKG"
sudo installer -pkg "$PKG" -target /
+ sudo "/Applications/Python 3.12/Install Certificates.command"
/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 \
-m venv "$RUNNER_TEMP/clipcascade-python"
echo "$RUNNER_TEMP/clipcascade-python/bin" >> "$GITHUB_PATH"
From cb47bde0b80449a8ccd5a332bdef23ea4513a49b Mon Sep 17 00:00:00 2001
From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com>
Date: Sat, 24 Oct 2026 18:31:34 -0500
Subject: [PATCH 28/28] ci: move Apple Silicon release builds to macos-15
---
.github/workflows/README.md | 2 +-
.github/workflows/release.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 19f9c3a0..f3924468 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -190,7 +190,7 @@ The `macos` scope builds two native packages with the same macOS version:
| Processor | GitHub runner | Archive |
|---|---|---|
-| Apple Silicon | `macos-14` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` |
+| Apple Silicon | `macos-15` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` |
| Intel | `macos-15-intel` (`x86_64`) | `ClipCascade-Apple_macOS(Intel-Series).zip` |
Both architectures use exactly **Python 3.12.10** from the signed python.org
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d1a0e39a..d15cebb2 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -223,7 +223,7 @@ jobs:
fail-fast: false
matrix:
include:
- - runner: macos-14
+ - runner: macos-15
arch: arm64
archive: ClipCascade-Apple_macOS(ARM_M-Series).zip
- runner: macos-15-intel