diff --git a/ClipCascade_Desktop/src/cli/info.py b/ClipCascade_Desktop/src/cli/info.py index bc20858b4..eab7f71ae 100644 --- a/ClipCascade_Desktop/src/cli/info.py +++ b/ClipCascade_Desktop/src/cli/info.py @@ -2,10 +2,12 @@ class CustomDialog: - def __init__(self, message, msg_type="", timeout=None): + def __init__(self, message, msg_type="", timeout=None, actions=None): self.message = message self.msg_type = msg_type.lower() self.timeout = timeout + self.actions = actions + self.result = None def mainloop(self): if self.msg_type == "success": @@ -18,6 +20,17 @@ def mainloop(self): Echo("ℹ", self.message) else: Echo(self.message) + if self.actions: + for index, (label, _) in enumerate(self.actions, 1): + Echo(f"{index}. {label}") + while True: + try: + answer = input("Choose an option: ").strip() + except EOFError: + return + if answer.isdigit() and 1 <= int(answer) <= len(self.actions): + self.result = self.actions[int(answer) - 1][1] + return def close(self): pass diff --git a/ClipCascade_Desktop/src/cli/login.py b/ClipCascade_Desktop/src/cli/login.py index bd8d3630e..63df87495 100644 --- a/ClipCascade_Desktop/src/cli/login.py +++ b/ClipCascade_Desktop/src/cli/login.py @@ -136,6 +136,18 @@ def mainloop(self): ) self._save_preference("save_password") + use_keyring = LoginForm.str_to_bool( + input( + f"Use OS credential storage [{LoginForm.bool_to_str(self.config.data['use_keyring'])}]: " + ) or LoginForm.bool_to_str(self.config.data["use_keyring"]) + ) + if not self.config.save_preferences({"use_keyring": use_keyring}): + CustomDialog( + "Unable to save the credential storage preference. Existing saved data has been preserved. " + "Check that the DATA file location is writable.", + msg_type="error", + ).mainloop() + # Validate max_clipboard_size_local_limit_bytes while True: saved_mcsll = str( diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py index 9e1db4be1..c501a5b31 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py @@ -3,14 +3,18 @@ import json import logging import os +from contextlib import contextmanager import xxhash from PIL import Image from clipboard.file_utils import encode_files, local_file_paths +from clipboard.update_worker import ClipboardUpdateWorker, ClipboardSuperseded from core.constants import * from core.config import Config from utils.notification_manager import NotificationManager +from utils.transfer_limits import content_size +from utils import binary_protocol as binary if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: from cli.tray import TaskbarPanel @@ -39,6 +43,10 @@ def __init__(self, config: Config): self.previous_clipboard_hash = 0 self.sys_tray: TaskbarPanel = None self.is_files_download_enabled = False + self._update_worker = None + self._remote_text = None + self._can_send_content = None + self._attachment_loading = None if PLATFORM.startswith(LINUX) and XMODE: self.is_x_clipboard_owner = clipboard_monitor.is_x_clipboard_owner() @@ -58,8 +66,14 @@ def reset_files_download(self): self.sys_tray.disable_files_download() @staticmethod - def hash_clipboard(clipboard: str) -> int: - return xxhash.xxh64(clipboard.encode("utf-8") if isinstance(clipboard, str) else clipboard).intdigest() + def hash_clipboard(clipboard, kind="text") -> int: + header, parts = binary.content_parts(clipboard, kind) + digest = xxhash.xxh64() + digest.update(json.dumps(header, ensure_ascii=False, separators=(",", ":")).encode("utf-8")) + for part in parts: + for offset in range(0, len(part), 1024 * 1024): + digest.update(memoryview(part)[offset:offset + 1024 * 1024]) + return digest.intdigest() def is_clipboard_size_within_limit( self, clipboard_content: any, type_: str = "text" @@ -79,6 +93,9 @@ def is_clipboard_size_within_limit( files=clipboard_content ) + return self.is_content_size_within_limit(content_size_in_bytes) + + def is_content_size_within_limit(self, content_size_in_bytes): # Check if the content size exceeds the server limit max_allowed_size = self.config.data["maxsize"] if ( @@ -106,7 +123,7 @@ def is_clipboard_size_within_limit( return True - def has_clipboard_changed(self, payload: str) -> bool: + def has_clipboard_changed(self, payload, kind="text") -> bool: """ Check if the clipboard content has changed by comparing the current hash with the previous clipboard hash. @@ -117,31 +134,73 @@ def has_clipboard_changed(self, payload: str) -> bool: Returns: - True if the clipboard content has changed, False otherwise. """ - current_clipboard_hash = ClipboardManager.hash_clipboard(payload) + current_clipboard_hash = ClipboardManager.hash_clipboard(payload, kind) if current_clipboard_hash != self.previous_clipboard_hash: self.previous_clipboard_hash = current_clipboard_hash return True return False - def on_copy(self, copy_callback): + def on_copy(self, copy_callback, on_change=None, can_send_content=None): + self._can_send_content = can_send_content + if self._update_worker is not None: + self._update_worker.stop() + worker = ClipboardUpdateWorker( + self.prepare_clipboard, + lambda payload, kind, current, size: copy_callback( + payload, kind, is_current=current, content_size_bytes=size), + ) + self._update_worker = worker + + def enqueue(kind, content): + if kind == "text" and content == self._remote_text: + self._remote_text = None + return # An OS notification for our own received text write. + self._remote_text = None + worker.invalidate() + if on_change: + on_change() + self.reset_files_download() + if self.config.data.get("send_clipboard", True): + worker.submit(kind, content) + clipboard_monitor.on_update( - callback=lambda type_, content: self.clipboard_to_base64( - copy_callback, content, type_ - ), + callback=enqueue, enable_image_monitoring=self.config.data["enable_image_sharing"], enable_file_monitoring=self.config.data["enable_file_sharing"], ) - def clipboard_to_base64(self, callback, content: any, type_: str = "text"): + def get_loading_status(self): + loading = self._attachment_loading + if loading is not None: + message, check_current = loading + try: + check_current() + return message + except ClipboardSuperseded: + pass + return None + + @contextmanager + def _loading_attachment(self, message, check_current): + check_current() + loading = (message, check_current) + self._attachment_loading = loading + try: + yield + finally: + if self._attachment_loading is loading: + self._attachment_loading = None + + def prepare_clipboard(self, callback, content: any, type_: str = "text"): if not self.config.data.get("send_clipboard", True): return try: - self.reset_files_download() - + check_current = getattr(callback, "check_current", lambda: None) + check_current() type_ = type_.lower() if type_ == "text": if self.is_clipboard_size_within_limit(content, type_): - callback(content, type_) + callback(content, type_, content_size_bytes=content_size(content, type_)) elif type_ == "image": if isinstance(content, list): @@ -151,46 +210,65 @@ def clipboard_to_base64(self, callback, content: any, type_: str = "text"): ) content = Image.open(content[0]) - if self.is_clipboard_size_within_limit(content, type_): - content_str = ClipboardManager.convert_image_to_base64(img=content) - callback(content_str, type_) + with self._loading_attachment("Loading image…", check_current): + if isinstance(content, bytes): + raw = content + else: + with io.BytesIO() as output: + content.save(output, format=content.format or "PNG") + raw = output.getvalue() + check_current() + if self.is_content_size_within_limit(len(raw)): + callback(raw, type_, content_size_bytes=len(raw)) elif type_ == "files": if PLATFORM.startswith(LINUX): content = local_file_paths(content or []) if self.is_clipboard_size_within_limit(content, type_): - content_str = ClipboardManager.convert_files_to_base64( - file_paths=content - ) - if content_str != "{}": # Check if the JSON string is empty - callback(content_str, type_) + size = self.calculate_cumulative_file_size(content) + if self._can_send_content and not self._can_send_content(size): + logging.info("No connected peer accepts this file's content size") + return + with self._loading_attachment("Loading files…", check_current): + content_str, size = encode_files(content, check_current, with_size=True, raw=True) + check_current() + # Recheck actual bytes read in case a file grew during preparation. + if bool(content_str) and self.is_content_size_within_limit(size): + callback(content_str, type_, content_size_bytes=size) + except ClipboardSuperseded: + pass except Exception as e: - logging.error(f"Failed to convert clipboard data to base64: {e}") + logging.error(f"Failed to prepare clipboard data: {e}") - def base64_to_clipboard(self, base64_string: str, type_: str = "text"): + def cancel_preparation(self): + if self._update_worker is not None: + self._update_worker.invalidate() + + def content_to_clipboard(self, content: str | bytes | dict, type_: str = "text", is_current=lambda: True): if not self.config.data.get("receive_clipboard", True): return try: if type_ == "text": - txt = base64_string + txt = content if self.is_clipboard_size_within_limit(txt, type_): - if self.paste(txt, type_): + if is_current() and self.paste(txt, type_): self.notification_manager.notify_clipboard_received(txt, type_) elif type_ == "image": - img = ClipboardManager.convert_base64_to_image(base64_img=base64_string) - if self.is_clipboard_size_within_limit(img, type_): - if self.paste(img, type_): + raw = binary.original_content(content, type_) + img = Image.open(io.BytesIO(raw)) + img.load() + if self.is_content_size_within_limit(len(raw)): + if is_current() and self.paste(img, type_): self.notification_manager.notify_clipboard_received(img, type_) elif type_ == "files": - file_objects = ClipboardManager.convert_base64_to_files( - base64_json=base64_string - ) + file_objects = {name: io.BytesIO(value) for name, value in + binary.original_content(content, type_).items()} if self.is_clipboard_size_within_limit(file_objects, type_): - if self.paste(file_objects, type_): + if is_current() and self.paste(file_objects, type_): self.notification_manager.notify_clipboard_received(file_objects, type_) except Exception as e: - logging.error(f"Failed to convert base64 data to clipboard: {e}") + logging.error(f"Failed to apply clipboard content: {e}") @staticmethod def execute_command(*args, input_data): @@ -217,9 +295,12 @@ def paste(self, payload: any, payload_type: str = "text") -> bool: if not self.config.data.get("receive_clipboard", True): return False try: + if payload_type != "text": + self._remote_text = None self.reset_files_download() if payload_type == "text": + self._remote_text = payload if PLATFORM == MACOS: clipboard_monitor.write_to_pasteboard(payload, pasteboard.String) elif PLATFORM == WINDOWS: @@ -256,7 +337,6 @@ def paste(self, payload: any, payload_type: str = "text") -> bool: payload.convert("RGB").save(output, format="TIFF") tiff_data = output.getvalue() - clipboard_monitor.enable_block_image_once() # Block image copy to prevent deadlock clipboard_monitor.write_to_pasteboard(tiff_data, pasteboard.TIFF) elif PLATFORM.startswith(LINUX): # Save the image to a binary buffer in PNG format @@ -303,6 +383,9 @@ def paste(self, payload: any, payload_type: str = "text") -> bool: raise def stop(self): + if self._update_worker is not None: + self._update_worker.stop() + self._update_worker = None self.reset_files_download() clipboard_monitor.stop() diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py index 5706b9084..c18960aa2 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py @@ -13,27 +13,25 @@ from utils.main_thread import on_main_thread _session = None -_block_image_once = False +_image_write_change_count = None _pb_writer = None +_MAX_EMPTY_READS = 10 @on_main_thread def write_to_pasteboard(data, pb_type): - global _pb_writer + global _pb_writer, _image_write_change_count try: if _pb_writer is None: _pb_writer = pasteboard.Pasteboard() _pb_writer.set_contents(data, pb_type) + if pb_type in (pasteboard.PNG, pasteboard.TIFF): + # Bind echo suppression to our write, never to the next user image. + _image_write_change_count = NSPasteboard.generalPasteboard().changeCount() except Exception: logging.exception("Failed to write to pasteboard") -@on_main_thread -def enable_block_image_once(): - global _block_image_once - _block_image_once = True - - def _deliver(session): while not session['stop'].is_set(): try: @@ -49,7 +47,7 @@ def _deliver(session): def _poll(session): - global _block_image_once + global _image_write_change_count if _session is not session or session['stop'].is_set(): return try: @@ -62,6 +60,13 @@ def _poll(session): change_count = session['board'].changeCount() if change_count == session['change_count']: return + if _image_write_change_count is not None: + own_image = change_count == _image_write_change_count + _image_write_change_count = None + if own_image: + session['change_count'] = change_count + logging.info("macOS clipboard: suppressed received-image write (change %s)", change_count) + return # Use one change counter for the entire clipboard. Per-format diff # cursors can get out of step when the clipboard changes during a read, @@ -79,11 +84,20 @@ def _poll(session): # Discard a mixed snapshot and retry the latest version next poll. if session['board'].changeCount() != change_count: return + if event is None: + # Browsers can announce a change before their image data is ready. + # Retry the same version, but bound retries for unsupported content. + attempts = session.get('empty_reads', 0) + 1 if session.get('empty_change') == change_count else 1 + session['empty_change'], session['empty_reads'] = change_count, attempts + if attempts < _MAX_EMPTY_READS: + if attempts == 1: + logging.info("macOS clipboard: no readable supported data yet; retrying change %s", change_count) + return + logging.info("macOS clipboard: no readable supported data after %s polls (change %s)", attempts, change_count) session['change_count'] = change_count - if event and event[0] == 'image' and _block_image_once: - _block_image_once = False - return if event: + if event[0] == 'image': + logging.info("macOS clipboard: queued image (%s bytes, change %s)", len(event[1]), change_count) # Only the latest clipboard matters if conversion falls behind. try: session['queue'].get_nowait() @@ -117,7 +131,7 @@ def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=Fa @on_main_thread def stop(): - global _session, _block_image_once, _pb_writer + global _session, _image_write_change_count, _pb_writer if _session is not None: _session['stop'].set() # Release native references on the main thread too; never join a worker @@ -125,7 +139,7 @@ def stop(): _session['reader'] = None _session['board'] = None _session = None - _block_image_once = False + _image_write_change_count = None _pb_writer = None diff --git a/ClipCascade_Desktop/src/clipboard/file_utils.py b/ClipCascade_Desktop/src/clipboard/file_utils.py index ca6adefbe..d0f775844 100644 --- a/ClipCascade_Desktop/src/clipboard/file_utils.py +++ b/ClipCascade_Desktop/src/clipboard/file_utils.py @@ -45,12 +45,14 @@ def local_file_paths(paths): return result -def encode_files(file_paths): +def encode_files(file_paths, check_current=lambda: None, with_size=False, raw=False): paths = [path for path in file_paths if os.path.isfile(path)] reserved = {os.path.basename(path).casefold() for path in paths} used = set() encoded = {} + total_size = 0 for path in paths: + check_current() name = os.path.basename(path) stem, extension = os.path.splitext(name) candidate = name @@ -63,5 +65,18 @@ def encode_files(file_paths): break used.add(candidate.casefold()) with open(path, "rb") as stream: - encoded[candidate] = base64.b64encode(stream.read()).decode("ascii") - return json.dumps(encoded) + parts = [] + # Multiples of three keep concatenated Base64 identical to the + # legacy whole-file encoding, with no padding between reads. + while True: + check_current() + block = stream.read(3 * 256 * 1024) + if not block: + break + total_size += len(block) + parts.append(block if raw else base64.b64encode(block).decode("ascii")) + encoded[candidate] = b"".join(parts) if raw else "".join(parts) + del parts + check_current() + payload = encoded if raw else json.dumps(encoded) + return (payload, total_size) if with_size else payload diff --git a/ClipCascade_Desktop/src/clipboard/update_worker.py b/ClipCascade_Desktop/src/clipboard/update_worker.py new file mode 100644 index 000000000..f99cfca09 --- /dev/null +++ b/ClipCascade_Desktop/src/clipboard/update_worker.py @@ -0,0 +1,77 @@ +"""Prepare clipboard snapshots without blocking the platform monitor.""" +import logging +import threading + + +class ClipboardSuperseded(Exception): + """The clipboard snapshot is no longer current.""" + + +class ClipboardUpdateWorker: + """One active snapshot and one replaceable pending snapshot per session.""" + + def __init__(self, prepare, deliver): + self._prepare = prepare + self._deliver = deliver + self._condition = threading.Condition() + self._pending = None + self._generation = 0 + self._stopped = False + self._thread = threading.Thread( + target=self._run, name="ClipboardPreparation", daemon=True + ) + self._thread.start() + + def submit(self, kind, content): + with self._condition: + if self._stopped: + return + self._generation += 1 + self._pending = (self._generation, kind, content) + self._condition.notify() + + def stop(self): + # Never join here: a worker may be waiting for platform/UI dispatch. + with self._condition: + self._stopped = True + self._generation += 1 + self._pending = None + self._condition.notify() + + def invalidate(self): + with self._condition: + self._generation += 1 + self._pending = None + + def _run(self): + while True: + with self._condition: + self._condition.wait_for(lambda: self._stopped or self._pending is not None) + if self._stopped: + return + generation, kind, content = self._pending + self._pending = None + + def is_current(generation=generation): + with self._condition: + return not self._stopped and generation == self._generation + + def deliver(payload, payload_type, content_size_bytes=None): + if is_current(): + self._deliver(payload, payload_type, is_current, content_size_bytes) + + def check_current(): + if not is_current(): + raise ClipboardSuperseded() + + deliver.check_current = check_current + + try: + self._prepare(deliver, content, kind) + except ClipboardSuperseded: + pass + except Exception: + logging.exception("Error preparing clipboard update") + finally: + # Do not retain the previous snapshot while waiting for another. + content = None diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py index c433b8303..36d1bbff1 100644 --- a/ClipCascade_Desktop/src/core/application.py +++ b/ClipCascade_Desktop/src/core/application.py @@ -176,7 +176,7 @@ def authenticate_and_connect(self): ws_conn_successful, msg = self._get_ws_manager().connect() if ws_conn_successful: self._get_ws_manager().is_login_phase = False - return + return False # Reused saved login; keep its current storage. # enable login form used_saved_credentials = False @@ -252,6 +252,8 @@ def authenticate_and_connect(self): if PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI: Echo("-" * 53) + return display_login_success_dialog # A form login commits the storage choice. + def _get_ws_manager(self): return self.transport_manager @@ -330,15 +332,50 @@ def _shutdown_macos(self, logoff=False): self._cleanup_lock_file() self._macos_shutdown_complete = True + def load_saved_config(self): + while not self.config.load(): + if self.config.credential_load_error is None: + return # First run: there is no saved login to recover. + dialog = CustomDialog( + "ClipCascade is not connected because saved login data could not be accessed.\n\n" + + self.config.credential_load_error + + ( + "\n\nRetry may show an OS unlock or permission prompt. " + if self.config.credentials_use_keyring + else "\n\nRetry after checking the saved DATA file. " + ) + + "You can also sign in manually or exit.", + msg_type="warning", + actions=[("Retry", "retry"), ("Sign in", "sign_in"), ("Exit", "exit")], + ) + dialog.mainloop() + if dialog.result == "sign_in": + return + if dialog.result != "retry": + sys.exit(0) + def run(self): try: self.banner() self.setup_logging() self.ensure_single_instance() - self.config.load() + self.load_saved_config() self.update_checker.start() - self.authenticate_and_connect() - self.config.save() + manual_login = self.authenticate_and_connect() + if not self.config.save(commit_storage=manual_login): + storage_help = ( + "Check that the OS credential store is available, unlocked, and allows ClipCascade access. " + "If running from source, also check that the Python 'keyring' package is installed " + "in the same environment used to run ClipCascade." + if (self.config.data["use_keyring"] if manual_login else self.config.credentials_use_keyring) + else "Check that the DATA file location is writable." + ) + CustomDialog( + "ClipCascade is connected, but the login could not be saved. " + "Automatic login may not work next time. Existing saved data has been preserved.\n\n" + + storage_help, + msg_type="warning", + ).mainloop() donation_url = self.get_donation_url() sys_tray = TaskbarPanel( diff --git a/ClipCascade_Desktop/src/core/clipboard_limit.py b/ClipCascade_Desktop/src/core/clipboard_limit.py index e03dda776..6ce82c8e7 100644 --- a/ClipCascade_Desktop/src/core/clipboard_limit.py +++ b/ClipCascade_Desktop/src/core/clipboard_limit.py @@ -25,6 +25,14 @@ def parse_clipboard_limit(amount, unit): raise ValueError("Enter a valid clipboard size limit.") from error -def format_clipboard_limit(value): - # Preserve the original byte value and blank-input behavior on every load. +def format_clipboard_limit(value, display=None): + if isinstance(display, dict): + amount, unit = display.get("amount"), display.get("unit") + if isinstance(amount, str) and isinstance(unit, str): + try: + if parse_clipboard_limit(amount, unit) == value: + return amount, unit + except ValueError: + pass + # Older settings and byte counts changed outside the editor use Bytes. return ("" if value is None or value == "" else str(int(value))), "Bytes" diff --git a/ClipCascade_Desktop/src/core/config.py b/ClipCascade_Desktop/src/core/config.py index ebc326b29..0821cb4f9 100644 --- a/ClipCascade_Desktop/src/core/config.py +++ b/ClipCascade_Desktop/src/core/config.py @@ -3,16 +3,19 @@ import os import re import tempfile +import uuid from core.constants import * +from utils import secret_storage class Config: + SECRET_FIELDS = ("hashed_password", "cookie", "password", "csrf_token") DEVICE_PREFERENCE_FIELDS = frozenset({ "notification", "update_notifications", "download_notifications", "clipboard_notifications", "save_password", - "max_clipboard_size_local_limit_bytes", + "max_clipboard_size_local_limit_bytes", "clipboard_limit_display", "enable_image_sharing", "enable_file_sharing", "default_file_download_location", "send_clipboard", "receive_clipboard", - "hide_login_success_dialog", + "hide_login_success_dialog", "use_keyring", }) def __init__(self, file_name=DATA_FILE_NAME): @@ -34,8 +37,10 @@ def __init__(self, file_name=DATA_FILE_NAME): "clipboard_notifications": False, "hide_login_success_dialog": False, "save_password": False, + "use_keyring": False, "password": "", "max_clipboard_size_local_limit_bytes": None, + "clipboard_limit_display": None, "send_clipboard": True, "receive_clipboard": True, "enable_image_sharing": True, @@ -46,71 +51,219 @@ def __init__(self, file_name=DATA_FILE_NAME): "ssl_ca_bundle": "", } self._saved_data = self.data.copy() + self._secret_ref = None + self._credentials_unavailable = False + self.credential_load_error = None - def save(self): + @property + def credentials_use_keyring(self): + """Where the existing saved login lives, independently of the next choice.""" + return bool(self._secret_ref) + + def save(self, *, commit_storage=False): """ - Save data to file + Save login data, applying a pending storage choice only after manual login. + Return False if preferences or credentials cannot be saved. """ - return self._save_data(self.data.copy()) + saved = self.data.copy() + use_keyring = saved["use_keyring"] if commit_storage else self.credentials_use_keyring + if not use_keyring: + return self._save_data(saved, use_keyring=False) + if not saved["cipher_enabled"]: + saved["hashed_password"] = None + if not saved["save_password"] or saved["cipher_enabled"]: + saved["password"] = "" + if self._save_data(saved, use_keyring=True): + return True + if self._secret_ref is None and not self._credentials_unavailable and not any( + self._saved_data[key] for key in self.SECRET_FIELDS + ) and any( + saved[key] for key in self.SECRET_FIELDS + ): + # A desktop without an available keyring can still keep preferences. + # Never use this fallback to overwrite credentials from an older install. + saved.update(hashed_password=None, cookie=None, password="", csrf_token="") + self._save_data(saved, use_keyring=True) + # Saving preferences alone does not mean the login was saved. + return False + return False def save_preferences(self, updates): """Persist only device preferences, preserving the last saved login settings.""" if not updates.keys() <= self.DEVICE_PREFERENCE_FIELDS: raise ValueError("Only device preferences can be saved independently") + if updates.keys() == {"use_keyring"} or self._credentials_unavailable: + # Preferences can still be saved while recovering a failed login. + # Read the persisted document, not the incomplete saved snapshot or + # a pending manual login. Keep its credential reference and login + # fields intact, without accessing the unavailable credential store. + try: + if os.path.isfile(self.file_name): + with open(self.file_name, "r") as source: + saved = json.load(source) + saved.update(updates) + self._write_data_file(saved) + self._saved_data.update(updates) + else: + saved = self._saved_data.copy() + saved.update(updates) + if not self._save_data(saved, use_keyring=self.credentials_use_keyring): + return False + self.data.update(updates) + return True + except Exception as error: + logging.error("Could not save device preferences (%s)", type(error).__name__) + return False saved = self._saved_data.copy() saved.update(updates) if not saved["save_password"]: saved["password"] = "" - if self._save_data(saved): + if self._save_data(saved, use_keyring=self.credentials_use_keyring): self.data.update(updates) + # Only the saved snapshot is cleared above. The CLI may already + # hold a newly typed password needed for the pending login. return True return False - def _save_data(self, data): + def _write_data_file(self, data): temporary_path = None try: - temp = data.copy() - if isinstance(temp.get("hashed_password"), bytes): - temp["hashed_password"] = base64.b64encode( - temp["hashed_password"] - ).decode("utf-8") with tempfile.NamedTemporaryFile( - mode="w", dir=os.path.dirname(os.path.abspath(self.file_name)), - delete=False, - ) as f: - temporary_path = f.name - json.dump(temp, f, indent=4) + mode="w", dir=os.path.dirname(os.path.abspath(self.file_name)), delete=False, + ) as target: + temporary_path = target.name + json.dump(data, target, indent=4) os.replace(temporary_path, self.file_name) + finally: + if temporary_path and os.path.exists(temporary_path): + os.unlink(temporary_path) + + def _save_data(self, data, *, use_keyring): + # A failed read must not turn an empty in-memory login into deletion of + # the existing entry. A successful manual login can save new secrets. + if self._credentials_unavailable and not any(data[key] for key in self.SECRET_FIELDS): + return False + old_ref = self._secret_ref + new_ref = old_ref + store = None + new_entry_written = False + new_entry_verified = False + try: + temp = data.copy() + secrets = {key: temp.pop(key) for key in self.SECRET_FIELDS} + if not use_keyring: + # Default desktop behavior: keep login data in DATA. Base64 is + # only serialization for the derived key, not encryption. + if isinstance(secrets["hashed_password"], bytes): + secrets["hashed_password"] = base64.b64encode(secrets["hashed_password"]).decode("ascii") + temp.update(secrets) + new_ref = None + elif any(secrets.values()): + if old_ref is None or self._credentials_unavailable or any( + data[key] != self._saved_data[key] + for key in (*self.SECRET_FIELDS, "server_url", "username") + ): + if isinstance(secrets["hashed_password"], bytes): + secrets["hashed_password"] = base64.b64encode(secrets["hashed_password"]).decode("ascii") + store = secret_storage.backend() + new_ref = uuid.uuid4().hex + value = json.dumps({ + "server_url": data["server_url"], + "username": data["username"], + "secrets": secrets, + }) + store.set_password(secret_storage.SERVICE, new_ref, value) + new_entry_written = True + if store.get_password(secret_storage.SERVICE, new_ref) != value: + raise RuntimeError("Credential store did not retain the new entry") + new_entry_verified = True + temp["secret_ref"] = new_ref + else: + new_ref = None + self._write_data_file(temp) + self._secret_ref = new_ref + self._credentials_unavailable = False + self.credential_load_error = None self._saved_data = data.copy() + if old_ref and old_ref != new_ref: + self._delete_secret(old_ref, store) return True except Exception as e: - logging.error(f"Failed to save data: {e}") + # A failed access/verification may mean the user just denied a + # prompt. Do not immediately ask again through a delete operation. + if new_entry_verified: + self._delete_secret(new_ref, store) + elif new_entry_written: + logging.warning( + "A new credential-store entry could not be verified; " + "leaving it untouched to avoid another access prompt" + ) + logging.error("Could not save ClipCascade data (%s)", type(e).__name__) return False - finally: - if temporary_path and os.path.exists(temporary_path): - os.unlink(temporary_path) + + @staticmethod + def _delete_secret(ref, store=None): + try: + (store if store is not None else secret_storage.backend()).delete_password( + secret_storage.SERVICE, ref + ) + except Exception as e: + logging.warning("Could not remove an old credential-store entry (%s)", type(e).__name__) def load(self): """ Load data from file """ + self.credential_load_error = None + reading_keyring = False if os.path.isfile(self.file_name): try: with open(self.file_name, "r") as f: file_data = json.load(f) - self.data.update(file_data) - # Decode hashed_password if present - if self.data.get("hashed_password"): - self.data["hashed_password"] = base64.b64decode( - self.data["hashed_password"] - ) - self._saved_data = self.data.copy() + self._secret_ref = file_data.pop("secret_ref", None) + reading_keyring = self.credentials_use_keyring + legacy = {key: file_data.pop(key) for key in self.SECRET_FIELDS if key in file_data} + file_data.setdefault("use_keyring", False) + self.data.update(file_data) + loaded = self.data.copy() + loaded.update(hashed_password=None, cookie=None, password="", csrf_token="") + if not isinstance(loaded["use_keyring"], bool): + raise ValueError("Invalid credential storage preference") + if self._secret_ref: + value = secret_storage.get_password(self._secret_ref) + if value is None: + self.credential_load_error = "The saved credential entry could not be found." + raise RuntimeError("Saved credentials are unavailable") + entry = json.loads(value) + if entry["server_url"] != loaded["server_url"] or entry["username"] != loaded["username"]: + self._secret_ref = None + raise ValueError("Saved credentials belong to a different login") + secrets = entry["secrets"] + loaded.update({key: secrets[key] for key in self.SECRET_FIELDS if key in secrets}) + else: + loaded.update(legacy) + if loaded.get("hashed_password"): + loaded["hashed_password"] = base64.b64decode(loaded["hashed_password"]) + self.data = loaded + self._saved_data = loaded.copy() + self._credentials_unavailable = False return True except Exception as e: - logging.error(f"Failed to load data: {e}") - logging.error( - "Try deleting DATA file in the program directory, and re-run the program again" - ) + self._credentials_unavailable = True + self.data.update(hashed_password=None, cookie=None, password="", csrf_token="") + if self.credential_load_error is None: + self.credential_load_error = ( + "The credential service is unavailable." + if secret_storage.is_service_unavailable(e) + else ( + "Saved login data could not be read. The credential store may be locked, " + "access may have been denied or cancelled, or the saved data may be invalid." + if reading_keyring + else "The saved DATA file could not be read or contains invalid login data." + ) + ) + self._saved_data = self.data.copy() + logging.warning("Could not load saved ClipCascade credentials (%s)", type(e).__name__) return False @staticmethod diff --git a/ClipCascade_Desktop/src/gui/info.py b/ClipCascade_Desktop/src/gui/info.py index f62c70fe8..7e688a04b 100644 --- a/ClipCascade_Desktop/src/gui/info.py +++ b/ClipCascade_Desktop/src/gui/info.py @@ -10,13 +10,15 @@ class CustomDialog(GuiWindow): - def __init__(self, message, msg_type="info", timeout=None, on_dont_show_again=None): + def __init__(self, message, msg_type="info", timeout=None, on_dont_show_again=None, actions=None): super().__init__() self.message = message self.msg_type = msg_type.lower() self.timeout = timeout self.after_id = None self.on_dont_show_again = on_dont_show_again + self.actions = actions + self.result = None self.dont_show_again = tk.BooleanVar(master=self, value=False) self.style = ttk.Style(self) @@ -39,7 +41,7 @@ def __init__(self, message, msg_type="info", timeout=None, on_dont_show_again=No self.after_id = self.after(self.timeout, self.close) # Allow closing with keyboard shortcuts - self.bind("", lambda e: self.close()) + self.bind("", self._on_return) self.bind("", lambda e: self.close()) self.protocol("WM_DELETE_WINDOW", self.close) @@ -122,11 +124,28 @@ def _show_dialog(self): button_frame = ttk.Frame(main_frame, style="Dialog.TFrame") button_frame.grid(row=3, column=0, pady=(10, 0)) - ok_button = ttk.Button( - button_frame, text="OK", command=self.close, width=10, style="Dialog.TButton" - ) - ok_button.pack() - ok_button.focus_set() + for index, (label, value) in enumerate(self.actions or [("OK", None)]): + button = ttk.Button( + button_frame, text=label, command=lambda choice=value: self._choose(choice), + width=12, style="Dialog.TButton", + ) + button.pack(side="left", padx=4) + if index == 0: + button.focus_set() + + def _choose(self, value): + self.result = value + self.close() + + def _on_return(self, event): + focused = self.focus_get() + if isinstance(focused, ttk.Button): + focused.invoke() + elif self.actions: + self._choose(self.actions[0][1]) + else: + self.close() + return "break" def _macos_restore_focus(self): """Force macOS to properly activate and focus the dialog.""" diff --git a/ClipCascade_Desktop/src/gui/login.py b/ClipCascade_Desktop/src/gui/login.py index 2660b0373..7c464e0ab 100644 --- a/ClipCascade_Desktop/src/gui/login.py +++ b/ClipCascade_Desktop/src/gui/login.py @@ -9,6 +9,7 @@ from utils.window_manager import center_window from utils.notification_manager import NotificationManager +from utils.secret_storage import STORAGE_HELP from core.config import Config from core.clipboard_limit import ( LIMIT_UNITS, NO_LOCAL_LIMIT, format_clipboard_limit, parse_clipboard_limit, @@ -56,6 +57,11 @@ def _show(self, _event=None): pady=6, ) label.pack() + tw.update_idletasks() + # Long storage help must remain visible near the bottom/right edge. + x = max(0, min(x, tw.winfo_screenwidth() - tw.winfo_reqwidth())) + y = max(0, min(y, tw.winfo_screenheight() - tw.winfo_reqheight())) + tw.wm_geometry(f"+{x}+{y}") def _hide(self, _event=None): if self.tooltip_window: @@ -376,6 +382,15 @@ def __init__( "Note: only works when encryption is disabled. Not recommended on shared or untrusted devices.", ) + keyring_label = ttk.Label(self.security_settings_frame, text="Use OS credential storage:") + keyring_label.grid(row=3, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.use_keyring_var = tk.BooleanVar(value=self.config.data["use_keyring"]) + self.use_keyring_checkbox = ttk.Checkbutton( + self.security_settings_frame, variable=self.use_keyring_var, takefocus=False, + ) + self.use_keyring_checkbox.grid(row=3, column=1, padx=10, pady=5, sticky=tk.W) + self._add_tooltip([keyring_label, self.use_keyring_checkbox], STORAGE_HELP) + # Keep the saved preference in bytes while editing in readable units. local_clipboard_size_label = ttk.Label( self.clipboard_settings_frame, text="Local clipboard size limit:" @@ -384,7 +399,10 @@ def __init__( limit_frame = ttk.Frame(self.clipboard_settings_frame) limit_frame.grid(row=0, column=1, padx=10, pady=5, sticky=tk.EW) limit_frame.columnconfigure(0, weight=1) - amount, unit = format_clipboard_limit(self.config.data["max_clipboard_size_local_limit_bytes"]) + amount, unit = format_clipboard_limit( + self.config.data["max_clipboard_size_local_limit_bytes"], + self.config.data["clipboard_limit_display"], + ) self.clipboard_limit_amount_var = tk.StringVar(value=amount) self.clipboard_limit_unit_var = tk.StringVar(value=unit) self.local_clipboard_size_entry = ttk.Entry( @@ -517,9 +535,9 @@ def __init__( # SSL CA bundle (private / corporate PKI) ssl_ca_label = ttk.Label(self.security_settings_frame, text="Custom CA certificate bundle:") - ssl_ca_label.grid(row=3, column=0, padx=(0, 10), pady=5, sticky=tk.W) + ssl_ca_label.grid(row=4, column=0, padx=(0, 10), pady=5, sticky=tk.W) ca_path_frame = ttk.Frame(self.security_settings_frame) - ca_path_frame.grid(row=3, column=1, padx=10, pady=5, sticky=tk.EW) + ca_path_frame.grid(row=4, column=1, padx=10, pady=5, sticky=tk.EW) ca_path_frame.columnconfigure(0, weight=1) self.ssl_ca_bundle_entry = ttk.Entry( ca_path_frame, width=30, font=("Helvetica", 13) @@ -618,6 +636,7 @@ def __init__( ("download_notifications", self.download_notifications_var), ("clipboard_notifications", self.clipboard_notifications_var), ("save_password", self.save_password_var), + ("use_keyring", self.use_keyring_var), ("send_clipboard", self.send_clipboard_var), ("receive_clipboard", self.receive_clipboard_var), ("enable_image_sharing", self.enable_image_sharing_var), @@ -841,7 +860,21 @@ def remove_trailing_slash(entry): return re.sub(r"/+$", "", entry) def _save_preference(self, field, value): + if getattr(self, "_restoring_storage_preference", False): + return if not self.config.save_preferences({field: value}): + if field == "use_keyring": + self._restoring_storage_preference = True + try: + self.use_keyring_var.set(self.config.data["use_keyring"]) + finally: + self._restoring_storage_preference = False + CustomDialog( + "Unable to save the credential storage preference. Existing saved data has been preserved. " + "Check that the DATA file location is writable.", + msg_type="error", + ).mainloop() + return CustomDialog("Unable to save device preferences.", msg_type="error").mainloop() def _change_clipboard_limit_unit(self, *_): @@ -853,16 +886,23 @@ def _change_clipboard_limit_unit(self, *_): self._save_clipboard_limit() self._sync_login_tab_order() + def _clipboard_limit_preferences(self): + amount = self.clipboard_limit_amount_var.get().strip() + unit = self.clipboard_limit_unit_var.get() + return { + "max_clipboard_size_local_limit_bytes": parse_clipboard_limit(amount, unit), + "clipboard_limit_display": {"amount": amount, "unit": unit}, + } + def _save_clipboard_limit(self, *_): try: - value = parse_clipboard_limit( - self.clipboard_limit_amount_var.get(), self.clipboard_limit_unit_var.get() - ) + preferences = self._clipboard_limit_preferences() except ValueError as error: self.clipboard_limit_error_var.set(str(error)) return # Incomplete edits must not overwrite the last saved limit. self.clipboard_limit_error_var.set("") - self._save_preference("max_clipboard_size_local_limit_bytes", value) + if not self.config.save_preferences(preferences): + CustomDialog("Unable to save device preferences.", msg_type="error").mainloop() def _save_preference_entry(self, field, value): value = value.strip() @@ -908,11 +948,10 @@ def on_login(self): self.config.data["salt"] = self.salt_entry.get() self.config.data["save_password"] = self.save_password_var.get() + self.config.data["use_keyring"] = self.use_keyring_var.get() try: - self.config.data["max_clipboard_size_local_limit_bytes"] = parse_clipboard_limit( - self.clipboard_limit_amount_var.get(), self.clipboard_limit_unit_var.get() - ) + self.config.data.update(self._clipboard_limit_preferences()) except ValueError as error: CustomDialog(f"Invalid Input\n{error}", msg_type="error").mainloop() return diff --git a/ClipCascade_Desktop/src/p2p/p2p_manager.py b/ClipCascade_Desktop/src/p2p/p2p_manager.py index af586aaf8..6b3836672 100644 --- a/ClipCascade_Desktop/src/p2p/p2p_manager.py +++ b/ClipCascade_Desktop/src/p2p/p2p_manager.py @@ -4,12 +4,17 @@ import websocket import asyncio import uuid +from collections import OrderedDict from threading import Lock, Thread from typing import Dict, List, Optional from core.config import Config from interfaces.ws_interface import WSInterface +from utils import binary_protocol as binary from utils.reconnect import ReconnectMixin +from utils.transfer_flow import send_shared_fragments, TransferSuperseded +from utils.transfer_limits import (SIZE_FIELD, valid_size, receive_limit, capability_message, + read_capability, allows_content, metadata_size, content_size) from utils.cipher_manager import CipherManager from clipboard.clipboard_manager import ClipboardManager from utils.notification_manager import NotificationManager @@ -50,6 +55,10 @@ def __init__(self, config: Config, is_login_phase=True): self._disconnect_future = None # Fragment variables + self._send_generation = 0 + self._preparation_lock = asyncio.Lock() + self._discarded_fragments = OrderedDict() + self._peer_receive_limits = {} self.sending_fragment_id = "" # The id of the fragment currently being sent # Mapping: fragment_id:str -> {"last_received_at": float, "fragments": list[str]} # Refresh the timestamp on each fragment so a dropped final @@ -100,7 +109,7 @@ def _dc_heartbeat_tick(self) -> None: if self.disconnected or self._p2p_shutting_down or not self.is_connected: return self._prune_stale_receiving_fragments() - ping = json.dumps({"_cc_keepalive": True}) + ping = capability_message(self.config) for ch in list(self.data_channels.values()): try: if getattr(ch, "readyState", "") == "open": @@ -145,6 +154,9 @@ def get_stats(self) -> str: stats = f"📊 Peers: {n}" if not self.is_connected: stats += " | Signaling: Reconnecting…" + loading = self.clipboard_manager.get_loading_status() + if loading: + stats += f" | {loading}" if self.sending_fragment_stats is not None: stats += f" | Sending: {self.sending_fragment_stats}" if self.receiving_fragment_stats is not None: @@ -199,7 +211,7 @@ def _connect_once(self) -> tuple[bool, str]: if not self.is_clipboard_monitoring_on: # Start clipboard monitoring self.is_clipboard_monitoring_on = True - self.clipboard_manager.on_copy(self.send) + self.clipboard_manager.on_copy(self.send, self._on_local_change, self.can_send_content) total_ms = 0 while self.is_connected is False: @@ -393,7 +405,10 @@ async def _cleanup_peer_connections(self, clear_bootstrap_state: bool = True): for peer_id in list(self._peer_connection_deadlines): self._cancel_peer_connection_deadline(peer_id) self._cancel_dc_heartbeat() + self.reset_sending_fragment_id() + self.clipboard_manager.cancel_preparation() self.reset_receiving_fragments() + self._peer_receive_limits.clear() try: try: for dc in list(self.data_channels.values()): @@ -473,6 +488,7 @@ async def _remove_stale_peers(self, updated_peers): self._peer_retry_attempts.pop(old_pid, None) self._pending_ice.pop(old_pid, None) # Close data channel + self._drop_peer_fragments(old_pid) dc = self.data_channels.pop(old_pid, None) if dc is not None: try: @@ -522,6 +538,7 @@ async def _dispose_peer_connection(self, peer_id: str) -> None: self._pending_ice.pop(peer_id, None) # Invalidate both objects before close callbacks can request recovery. pc = self.peer_connections.pop(peer_id, None) + self._drop_peer_fragments(peer_id) dc = self.data_channels.pop(peer_id, None) if dc is not None: try: @@ -833,18 +850,22 @@ def on_open(): self._cancel_peer_connection_deadline(remote_peer_id) self._peer_retry_attempts.pop(remote_peer_id, None) self._sync_live_connections_count() + channel.send(capability_message(self.config)) if channel.readyState == "open": on_open() @channel.on("message") def on_message(message): - self._receive(message) + if self.data_channels.get(remote_peer_id) is not channel: + return + self._receive(message, remote_peer_id) @channel.on("close") def on_close(): if self.data_channels.get(remote_peer_id) is not channel: return + self._drop_peer_fragments(remote_peer_id) pc = self.peer_connections.get(remote_peer_id) self._sync_live_connections_count() self.schedule_task(self._recover_peer_transport(remote_peer_id, pc)) @@ -854,73 +875,165 @@ def on_error(e): logging.error(f"[data] Data channel error with {remote_peer_id}: {e}") self._sync_live_connections_count() - def send(self, payload: str, payload_type: str = "text"): - self.schedule_task(self._send(payload, payload_type)) + def _on_local_change(self): + # Called on the clipboard monitor thread. Invalidate sends immediately; + # mutate receive buffers on their owning asyncio loop. + self.reset_sending_fragment_id() + self.loop.call_soon_threadsafe(self.reset_receiving_fragments) + + def can_send_content(self, size): + return any(channel.readyState == "open" and allows_content(self._peer_receive_limits.get(peer), size) + for peer, channel in self.data_channels.copy().items()) + + def send(self, payload: str, payload_type: str = "text", is_current=lambda: True, content_size_bytes=None): + self.schedule_task(self._send(payload, payload_type, self._send_generation, is_current, content_size_bytes)) def reset_sending_fragment_id(self): + self._send_generation += 1 self.sending_fragment_id = "" self.sending_fragment_stats = None - async def _send(self, payload: str, payload_type: str = "text"): - if not self.config.data.get("send_clipboard", True): - return - try: - if self.clipboard_manager.has_clipboard_changed(payload): - self.reset_sending_fragment_id() - self.reset_receiving_fragments() - - raw_payload_size_in_bytes = len(payload.encode("utf-8")) + async def _send(self, payload: str, payload_type: str = "text", generation=None, is_current=lambda: True, + content_size_bytes=None): + if generation is None: + generation = self._send_generation - if self.config.data["cipher_enabled"]: - payload = CipherManager.encode_to_json_string( - **self.cipher_manager.encrypt(payload) - ) - - fragments = P2PManager.fragment_string(payload) - metadata = { - "id": str(uuid.uuid4()), - "isFragmented": len(fragments) > 1, - "index": 0, - "totalFragments": len(fragments), - "combinedRawPayloadSizeInBytes": raw_payload_size_in_bytes, - } + def check_current(): + if (not is_current() or generation != self._send_generation or self.disconnected + or not self.config.data.get("send_clipboard", True)): + raise TransferSuperseded() - self.sending_fragment_id = metadata["id"] - for fragment in fragments: - if not self.config.data.get("send_clipboard", True): - self.reset_sending_fragment_id() - break - if self.sending_fragment_id != metadata["id"]: - break - - body = json.dumps( - { - "payload": fragment, - "type": payload_type, - "metadata": metadata, - } - ) - metadata["index"] += 1 - - # Send to all open DataChannels - for peer_id, channel in self.data_channels.items(): - if channel.readyState == "open": - channel.send(body) + transfer_id = str(uuid.uuid4()) + try: + if payload_type == "image": + logging.info("P2P image %s: send requested (%s bytes, encryption=%s)", + transfer_id, content_size_bytes, bool(self.config.data["cipher_enabled"])) + check_current() + if not valid_size(content_size_bytes): + content_size_bytes = await asyncio.to_thread(content_size, payload, payload_type) + check_current() + if not self.can_send_content(content_size_bytes): + logging.info("No connected peer accepts this clipboard content size") + return + # Hash/encode/encrypt can be large. Keep the receive loop available. + payload_hash = await asyncio.to_thread(self.clipboard_manager.hash_clipboard, payload, payload_type) + check_current() + if payload_hash == self.clipboard_manager.previous_clipboard_hash: + logging.info("P2P %s %s: skipped duplicate clipboard content", payload_type, transfer_id) + return + self.reset_receiving_fragments() - if metadata["isFragmented"]: - self.sending_fragment_stats = ( - f"{metadata['index']}/{metadata['totalFragments']}" - ) - else: - self.reset_sending_fragment_id() + recipients = [ch for peer, ch in self.data_channels.items() + if ch.readyState == "open" and allows_content(self._peer_receive_limits.get(peer), content_size_bytes)] + recipient_ids = {id(ch): peer for peer, ch in self.data_channels.items()} + encrypted = bool(self.config.data["cipher_enabled"]) + groups = [(flag, [ch for ch in recipients if bool(getattr(ch, "_cc_binary", False)) == flag]) + for flag in (False, True)] + + def prepare(): + prepared_groups = [] + legacy = None + def get_legacy(): + nonlocal legacy + if legacy is None: + legacy = binary.legacy_content(payload, payload_type) + return legacy + for use_binary, channels in groups: + if not channels: + continue + check_current() + raw_size = 0 + if use_binary: + def measure_raw_size(size): + nonlocal raw_size + raw_size = size + try: + prepared = binary.encode_content(payload, payload_type, + self.cipher_manager if encrypted else None, on_raw_size=measure_raw_size) + fragments = [memoryview(prepared)[i:i + FRAGMENT_SIZE] for i in range(0, len(prepared), FRAGMENT_SIZE)] + except ValueError as error: + if str(error) != "Binary header too large": + raise + fragments = [] + if not fragments or len(fragments) > 65536: + use_binary = False + raw_size = 0 + prepared = (CipherManager.encode_to_json_string(**self.cipher_manager.encrypt(get_legacy())) + if encrypted else get_legacy()) + raw_size = len(get_legacy().encode("utf-8")) + fragments = self.fragment_string(prepared, check_current=check_current) + else: + prepared = (CipherManager.encode_to_json_string(**self.cipher_manager.encrypt(get_legacy())) + if encrypted else get_legacy()) + raw_size = len(get_legacy().encode("utf-8")) + fragments = self.fragment_string(prepared, check_current=check_current) + check_current() + prepared_groups.append((use_binary, channels, fragments, raw_size)) + return prepared_groups + + if payload_type == "text" and len(payload) <= 65536: + prepared_groups = await asyncio.to_thread(prepare) + else: + async with self._preparation_lock: + check_current() + prepared_groups = await asyncio.to_thread(prepare) + check_current() + payload = None + self.sending_fragment_id = transfer_id + + def progress(index, total): + if self.sending_fragment_id == transfer_id and generation == self._send_generation: + self.sending_fragment_stats = f"{index}/{total}" + + async def send_group(use_binary, channels, fragments, raw_size): + metadata = dict(id=transfer_id, isFragmented=len(fragments) > 1, + index=0, totalFragments=len(fragments), + combinedRawPayloadSizeInBytes=raw_size, contentSizeBytes=content_size_bytes) + return await send_shared_fragments( + fragments, channels, metadata, payload_type, check_current, progress, + lambda ch: self.data_channels.get(recipient_ids[id(ch)]) is ch + and allows_content(self._peer_receive_limits.get(recipient_ids[id(ch)]), content_size_bytes), + (lambda part, meta: binary.encode_frame(part, payload_type, meta, encrypted)) if use_binary else None, + ) + completed = sum(await asyncio.gather(*(send_group(*group) for group in prepared_groups))) + if not completed: + logging.warning("P2P %s %s: no peer completed sending", payload_type, transfer_id) + return + check_current() + self.clipboard_manager.previous_clipboard_hash = payload_hash + if payload_type == "image": + logging.info("P2P image %s: sent to %s peer(s)", transfer_id, completed) + except TransferSuperseded: + logging.info("P2P %s %s: transfer canceled by newer clipboard content or connection state", payload_type, transfer_id) except Exception as e: logging.error(f"Failed to send data: {e}") + finally: + if self.sending_fragment_id == transfer_id: + self.sending_fragment_id = "" + self.sending_fragment_stats = None + + def _discard_fragment_id(self, fragment_id): + self._discarded_fragments[fragment_id] = None + self._discarded_fragments.move_to_end(fragment_id) + while len(self._discarded_fragments) > 1024: + self._discarded_fragments.popitem(last=False) def reset_receiving_fragments(self): - self.receiving_fragments = {} + for fragment_id in self.receiving_fragments: + self._discard_fragment_id(fragment_id) + self.receiving_fragments.clear() self.receiving_fragment_stats = None + def _drop_peer_fragments(self, peer_id): + self._peer_receive_limits.pop(peer_id, None) + for fid, entry in list(self.receiving_fragments.items()): + if entry.get("peer") == peer_id: + self._discard_fragment_id(fid) + self.receiving_fragments.pop(fid, None) + if not self.receiving_fragments: + self.receiving_fragment_stats = None + def _prune_stale_receiving_fragments(self) -> None: """Drop partial fragment buffers idle for P2P_FRAGMENT_TTL_SEC. Why: if a peer disconnects mid-transfer, the final fragment never arrives @@ -938,35 +1051,56 @@ def _prune_stale_receiving_fragments(self) -> None: for fid in stale: logging.debug("P2P: dropping stale fragment buffer %s", fid) self.receiving_fragments.pop(fid, None) + self._discard_fragment_id(fid) if not self.receiving_fragments: self.receiving_fragment_stats = None - def _receive(self, frame: any) -> str: - if not self.config.data.get("receive_clipboard", True): - self.reset_receiving_fragments() - return + def _receive(self, frame: any, peer_id=None) -> str: + generation = self._send_generation try: - body = json.loads(frame) + body = binary.decode_frame(frame) if isinstance(frame, bytes) else json.loads(frame) + is_binary = body.get("_cc_binary") is True if isinstance(body, dict) and body.get("_cc_keepalive") is True: + channel = self.data_channels.get(peer_id) + if channel is not None: + caps = body.get("capabilities") + channel._cc_binary = (isinstance(caps, dict) and caps.get("version") == 1 + and isinstance(caps.get("transferFormats"), list) + and "ccb1" in caps["transferFormats"]) + read_capability(body, self._peer_receive_limits, peer_id) + return + if not self.config.data.get("receive_clipboard", True): + self.reset_receiving_fragments() return - self.reset_sending_fragment_id() payload = body["payload"] payload_type = body.get("type", "text") metadata = body.get("metadata") - - # Check if the payload exceeds the maximum size: first layer protection - if ( - metadata is not None - and self.config.data["max_clipboard_size_local_limit_bytes"] is not None - and metadata["combinedRawPayloadSizeInBytes"] - > self.config.data["max_clipboard_size_local_limit_bytes"] - ): - self.reset_receiving_fragments() - logging.debug( - f"Payload size limit exceeded: {metadata['combinedRawPayloadSizeInBytes']} bytes exceeds {self.config.data['max_clipboard_size_local_limit_bytes']} bytes" - ) + if metadata is not None: + fid = metadata["id"] + if fid in self._discarded_fragments: + return + total, index = metadata["totalFragments"], metadata["index"] + if (not isinstance(total, int) or not isinstance(index, int) + or total < 1 or not 0 <= index < total): + raise ValueError("Invalid fragment metadata") + if fid not in self.receiving_fragments and index != 0: + return # Canceled/expired tails cannot resurrect a transfer. + # Legacy raw sizes include Base64/JSON overhead. Without an + # original-content size, defer to decoded-content validation. + if not allows_content(receive_limit(self.config), metadata_size(metadata)): + if metadata: + self.receiving_fragments.pop(metadata["id"], None) + self._discard_fragment_id(metadata["id"]) + if not self.receiving_fragments: + self.receiving_fragment_stats = None + logging.debug("Incoming clipboard content exceeds the local size limit") return + if metadata is None or metadata["index"] == 0: + self.reset_sending_fragment_id() + self.clipboard_manager.cancel_preparation() + generation = self._send_generation + # Fragmented message handling if metadata is not None and metadata["isFragmented"]: self._prune_stale_receiving_fragments() @@ -975,12 +1109,28 @@ def _receive(self, frame: any) -> str: ) if metadata["id"] in self.receiving_fragments: entry = self.receiving_fragments[metadata["id"]] + if (entry["peer"] != peer_id or len(entry["fragments"]) != metadata["totalFragments"] + or entry["type"] != payload_type or entry.get("binary", False) != is_binary + or entry.get("encrypted") != body.get("encrypted")): + self.reset_receiving_fragments() + return + if entry["generation"] != self._send_generation: + self.reset_receiving_fragments() + return + generation = entry["generation"] + if is_binary: + if (metadata["index"] != entry["next_index"] or metadata_size(metadata) != entry["size"] + or entry["bytes"] + len(payload) > entry["size"] + binary.MAX_HEADER + 64): + raise ValueError("Invalid binary fragment sequence or size") + entry["next_index"] += 1 + entry["bytes"] += len(payload) entry["fragments"][metadata["index"]] = payload entry["last_received_at"] = time.monotonic() if metadata["index"] == metadata["totalFragments"] - 1: if all(s != "" for s in entry["fragments"]): - payload = "".join(entry["fragments"]) + payload = (b"" if is_binary else "").join(entry["fragments"]) self.receiving_fragments.pop(metadata["id"], None) + self._discard_fragment_id(metadata["id"]) else: self.reset_receiving_fragments() logging.error( @@ -993,6 +1143,9 @@ def _receive(self, frame: any) -> str: self.reset_receiving_fragments() self.receiving_fragments[metadata["id"]] = { "last_received_at": time.monotonic(), + "peer": peer_id, "type": payload_type, "generation": generation, + "binary": is_binary, "encrypted": body.get("encrypted"), + "next_index": 1, "bytes": len(payload), "size": metadata_size(metadata), "fragments": [""] * metadata["totalFragments"], } self.receiving_fragments[metadata["id"]]["fragments"][ @@ -1000,23 +1153,37 @@ def _receive(self, frame: any) -> str: ] = payload return - if self.config.data["cipher_enabled"]: + if generation != self._send_generation: + return + self.reset_receiving_fragments() + if is_binary: + if body["encrypted"] != bool(self.config.data["cipher_enabled"]): + raise ValueError("Encryption settings differ between devices") + payload = binary.decode_content(payload, payload_type, + self.cipher_manager if body["encrypted"] else None, receive_limit(self.config)) + elif self.config.data["cipher_enabled"]: payload = self.cipher_manager.decrypt( **CipherManager.decode_from_json_string(payload) ) - if self.clipboard_manager.has_clipboard_changed(payload): + payload = binary.original_content(payload, payload_type) + if generation != self._send_generation: + return + if self.clipboard_manager.has_clipboard_changed(payload, payload_type): self.reset_receiving_fragments() - self.clipboard_manager.base64_to_clipboard( - base64_string=payload, type_=payload_type + self.clipboard_manager.content_to_clipboard( + content=payload, type_=payload_type, + is_current=lambda: generation == self._send_generation ) except json.decoder.JSONDecodeError: + self.reset_receiving_fragments() logging.error("If cipher is enabled, please make sure it is enabled on all devices") except Exception as e: + self.reset_receiving_fragments() logging.error(f"Failed to receive data: {e}") @staticmethod - def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]: + def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE, check_current=lambda: None) -> list[str]: """ Splits a string into a list of fragments, each with a maximum size of `fragment_size` bytes. @@ -1033,6 +1200,7 @@ def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]: fragments = [] start = 0 while start < len(encoded): + check_current() end = min(start + fragment_size, len(encoded)) while end < len(encoded) and encoded[end] & 0xC0 == 0x80: end -= 1 diff --git a/ClipCascade_Desktop/src/pyproject.toml b/ClipCascade_Desktop/src/pyproject.toml index f036c7ea7..5283bd3a2 100644 --- a/ClipCascade_Desktop/src/pyproject.toml +++ b/ClipCascade_Desktop/src/pyproject.toml @@ -32,8 +32,12 @@ classifiers = [ ] dependencies = [ - "Pillow>=10.4.0; python_version < '3.14'", - "Pillow==12.0.0; python_version >= '3.14'", + "keyring>=25.6,<26; python_version >= '3.9'", + "keyring==24.3.1; python_version < '3.9'", + "Pillow>=10.4.0; python_version < '3.9'", + "Pillow>=11.3.0; python_version == '3.9'", + "Pillow>=12.3.0; python_version == '3.10'", + "Pillow>=13.0.0; python_version >= '3.11'", "plyer==2.1.0", "pycryptodome==3.20.0", "pystray==0.19.5", diff --git a/ClipCascade_Desktop/src/requirements_linux.txt b/ClipCascade_Desktop/src/requirements_linux.txt index 8c75009d1..39bc72c37 100644 --- a/ClipCascade_Desktop/src/requirements_linux.txt +++ b/ClipCascade_Desktop/src/requirements_linux.txt @@ -1,5 +1,7 @@ -Pillow==10.4.0; python_version < "3.14" -Pillow==12.0.0; python_version >= "3.14" +Pillow==10.4.0; python_version < "3.9" +Pillow==11.3.0; python_version == "3.9" +Pillow==12.3.0; python_version == "3.10" +Pillow==13.0.0; python_version >= "3.11" plyer==2.1.0 pycryptodome==3.20.0 pystray==0.19.5 @@ -12,3 +14,5 @@ beautifulsoup4==4.12.3 aiortc==1.10.0; python_version < "3.14" aiortc==1.14.0; python_version >= "3.14" av>=16.0.0,<17; python_version >= "3.14" +keyring>=25.6,<26; python_version >= "3.9" +keyring==24.3.1; python_version < "3.9" diff --git a/ClipCascade_Desktop/src/requirements_linux_cli.txt b/ClipCascade_Desktop/src/requirements_linux_cli.txt index 3bd4a959c..b64f772db 100644 --- a/ClipCascade_Desktop/src/requirements_linux_cli.txt +++ b/ClipCascade_Desktop/src/requirements_linux_cli.txt @@ -1,3 +1,7 @@ +Pillow==10.4.0; python_version < "3.9" +Pillow==11.3.0; python_version == "3.9" +Pillow==12.3.0; python_version == "3.10" +Pillow==13.0.0; python_version >= "3.11" pycryptodome==3.20.0 pyfiglet==1.0.2 prompt_toolkit>=3.0.43,<4 @@ -8,3 +12,5 @@ beautifulsoup4==4.12.3 aiortc==1.10.0; python_version < "3.14" aiortc==1.14.0; python_version >= "3.14" av>=16.0.0,<17; python_version >= "3.14" +keyring>=25.6,<26; python_version >= "3.9" +keyring==24.3.1; python_version < "3.9" diff --git a/ClipCascade_Desktop/src/requirements_linux_gui.txt b/ClipCascade_Desktop/src/requirements_linux_gui.txt index 65849644a..c26346df6 100644 --- a/ClipCascade_Desktop/src/requirements_linux_gui.txt +++ b/ClipCascade_Desktop/src/requirements_linux_gui.txt @@ -1,5 +1,7 @@ -Pillow==10.4.0; python_version < "3.14" -Pillow==12.0.0; python_version >= "3.14" +Pillow==10.4.0; python_version < "3.9" +Pillow==11.3.0; python_version == "3.9" +Pillow==12.3.0; python_version == "3.10" +Pillow==13.0.0; python_version >= "3.11" plyer==2.1.0 pycryptodome==3.20.0 pystray==0.19.5 @@ -10,3 +12,5 @@ beautifulsoup4==4.12.3 aiortc==1.10.0; python_version < "3.14" aiortc==1.14.0; python_version >= "3.14" av>=16.0.0,<17; python_version >= "3.14" +keyring>=25.6,<26; python_version >= "3.9" +keyring==24.3.1; python_version < "3.9" diff --git a/ClipCascade_Desktop/src/requirements_mac.txt b/ClipCascade_Desktop/src/requirements_mac.txt index 45c97fc5f..8f8454b1e 100644 --- a/ClipCascade_Desktop/src/requirements_mac.txt +++ b/ClipCascade_Desktop/src/requirements_mac.txt @@ -4,8 +4,7 @@ certifi pyobjus==1.2.3 pasteboard==0.4.0 -Pillow==11.0.0; python_version < "3.14" -Pillow==12.0.0; python_version >= "3.14" +Pillow==13.0.0 pycryptodome==3.20.0 pyperclip==1.8.2 pystray==0.19.5 @@ -17,3 +16,5 @@ aiortc==1.10.0; python_version < "3.14" aiortc==1.14.0; python_version >= "3.14" av>=16.0.0,<17; python_version >= "3.14" pyobjc-framework-Cocoa>=10 +keyring>=25.6,<26; python_version >= "3.9" +keyring==24.3.1; python_version < "3.9" diff --git a/ClipCascade_Desktop/src/requirements_win.txt b/ClipCascade_Desktop/src/requirements_win.txt index fecc2b7d9..ee7f6040f 100644 --- a/ClipCascade_Desktop/src/requirements_win.txt +++ b/ClipCascade_Desktop/src/requirements_win.txt @@ -1,5 +1,4 @@ -Pillow==10.4.0; python_version < "3.14" -Pillow==12.0.0; python_version >= "3.14" +Pillow==13.0.0 plyer==2.1.0 pycryptodome==3.20.0 pyperclip==1.8.2 @@ -12,3 +11,5 @@ beautifulsoup4==4.12.3 aiortc==1.10.0; python_version < "3.14" aiortc==1.14.0; python_version >= "3.14" av>=16.0.0,<17; python_version >= "3.14" +keyring>=25.6,<26; python_version >= "3.9" +keyring==24.3.1; python_version < "3.9" diff --git a/ClipCascade_Desktop/src/stomp_ws/client.py b/ClipCascade_Desktop/src/stomp_ws/client.py index ec0b41c54..b8bb28ef9 100644 --- a/ClipCascade_Desktop/src/stomp_ws/client.py +++ b/ClipCascade_Desktop/src/stomp_ws/client.py @@ -72,13 +72,13 @@ def _on_error(self, ws_app, error, *args): logging.debug(error) def _on_message(self, ws_app, message, *args): - if message == "\n": # If message is a newline, it's a heartbeat frame + if message in ("\n", b"\n"): # If message is a newline, it's a heartbeat frame logging.debug("Received heartbeat frame") self.ws.send("\n") # Send a heartbeat back to the server logging.debug("Sent heartbeat frame") return - logging.debug("\n<<< " + str(message)) + logging.debug("STOMP receive (%d bytes)", len(message)) frame = Frame.unmarshall_single(message) if frame is None: # Malformed frame — log and continue rather than killing the receive loop. @@ -137,8 +137,11 @@ def nack(headers): def _transmit(self, command, headers, body=None): out = Frame.marshall(command, headers, body) - logging.debug("\n>>> " + out) - self.ws.send(out) + logging.debug("STOMP send %s (%d bytes)", command, len(out)) + if isinstance(out, bytes): + self.ws.send(out, opcode=websocket.ABNF.OPCODE_BINARY) + else: + self.ws.send(out) def connect( self, diff --git a/ClipCascade_Desktop/src/stomp_ws/frame.py b/ClipCascade_Desktop/src/stomp_ws/frame.py index 127657a8f..6b436f5d1 100644 --- a/ClipCascade_Desktop/src/stomp_ws/frame.py +++ b/ClipCascade_Desktop/src/stomp_ws/frame.py @@ -13,10 +13,10 @@ def __init__(self, command, headers, body): def __str__(self): # STOMP content-length counts UTF-8 bytes, not Python characters. headers = dict(self.headers) - headers["content-length"] = str(len(self.body.encode("utf-8"))) + headers["content-length"] = str(len(self.body) if isinstance(self.body, bytes) else len(self.body.encode("utf-8"))) lines = [self.command] lines.extend(f"{name}:{value}" for name, value in headers.items()) - return Byte["LF"].join(lines) + "\n\n" + self.body + return Byte["LF"].join(lines) + "\n\n" + (f"<{len(self.body)} binary bytes>" if isinstance(self.body, bytes) else self.body) @staticmethod def unmarshall_single(data): @@ -49,10 +49,14 @@ def unmarshall_single(data): if end < 0: return None body = body[:end] - return Frame(command, headers, body.decode("utf-8")) + return Frame(command, headers, body if headers.get("content-type", "").split(";")[0] == "application/octet-stream" else body.decode("utf-8")) except (ValueError, TypeError, UnicodeError): return None @staticmethod def marshall(command, headers, body): + if isinstance(body, bytes): + headers = {**headers, "content-length": str(len(body))} + prefix = "\n".join([command] + [f"{key}:{value}" for key, value in headers.items()]) + return prefix.encode("utf-8") + b"\n\n" + body + b"\0" return str(Frame(command, headers, body)) + Byte["NULL"] diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index efe942df2..3d5224d3c 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -3,11 +3,16 @@ from interfaces.ws_interface import WSInterface +from utils import binary_protocol as binary +from utils.relay_recipients import RelayRecipients from utils.reconnect import ReconnectMixin from stomp_ws.client import Client from core.config import Config from utils.cipher_manager import CipherManager from clipboard.clipboard_manager import ClipboardManager +from utils.clipboard_echoes import ClipboardEchoes +from utils.transfer_limits import (LIMIT_HEADER, SIZE_FIELD, receive_limit_header, valid_size, + metadata_size, allows_content, receive_limit, content_size) from utils.notification_manager import NotificationManager from utils.request_manager import RequestManager from utils.ssl_helper import websocket_sslopt_for_config @@ -22,6 +27,9 @@ class STOMPManager(ReconnectMixin, WSInterface): def __init__(self, config: Config, is_login_phase=True): self.config = config + self._relay_recipients = RelayRecipients() + self._transfer_generation = 0 + self._sent_echoes = ClipboardEchoes() self.clipboard_manager = ClipboardManager(self.config) self.cipher_manager = CipherManager(self.config) self.notification_manager = NotificationManager(self.config) @@ -47,7 +55,12 @@ def get_total_timeout(self): return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT def get_stats(self): - return "Connected" if self.is_connected else ("Disconnected" if self.disconnected else "Reconnecting…") + if not self.is_connected: + return "Disconnected" if self.disconnected else "Reconnecting…" + loading = self.clipboard_manager.get_loading_status() + if loading: + return f"Connected | {loading}" + return "Connected" def _connect_once(self) -> tuple[bool, str]: try: @@ -55,6 +68,7 @@ def _connect_once(self) -> tuple[bool, str]: return True, "" if self.client is not None: self.client.disconnect() + cache = self._relay_recipients = RelayRecipients() client = Client( self.config.data["websocket_url"], headers={ @@ -67,10 +81,11 @@ def _connect_once(self) -> tuple[bool, str]: ) self.client = client client.connect( + headers={"x-clipcascade-recipient-updates": "1", "x-clipcascade-binary": "ccb1", "x-clipcascade-no-self-echo": "true", LIMIT_HEADER: receive_limit_header(self.config)}, timeout=WEBSOCKET_TIMEOUT, connectCallback=lambda _: client.subscribe( # receive event destination=SUBSCRIPTION_DESTINATION, - callback=self._receive, + callback=lambda frame: self._receive(frame, cache) if self.client is client and not self.disconnected else None, ), ) if self.disconnected: @@ -91,7 +106,7 @@ def _connect_once(self) -> tuple[bool, str]: ) # send event - self.clipboard_manager.on_copy(self.send) + self.clipboard_manager.on_copy(self.send, self._on_local_change) return True, "Websocket connected" except Exception as e: self.is_connected = False @@ -107,6 +122,8 @@ def _connect_once(self) -> tuple[bool, str]: def _on_close(self, client=None): if client is not None and client is not self.client: return + self._relay_recipients.reset() + self._transfer_generation += 1 self.is_connected = False # Auto Reconnect if not self.is_login_phase and not self.disconnected: @@ -119,37 +136,110 @@ def _on_close(self, client=None): self.first_conn_lost = False self._start_reconnect() - def send(self, payload: str, payload_type: str = "text"): - if not self.config.data.get("send_clipboard", True): + def _on_local_change(self): + self._transfer_generation += 1 + + def send(self, payload: str, payload_type: str = "text", is_current=lambda: True, content_size_bytes=None): + generation = self._transfer_generation + client = self.client + if not is_current() or not self.config.data.get("send_clipboard", True): return try: if self.is_connected: - if self.clipboard_manager.has_clipboard_changed(payload): - if self.config.data["cipher_enabled"]: - payload = CipherManager.encode_to_json_string( - **self.cipher_manager.encrypt(payload) - ) - body = json.dumps({"payload": payload, "type": payload_type}) - self.client.send(destination=SEND_DESTINATION, body=body) + payload_hash = self.clipboard_manager.hash_clipboard(payload, payload_type) + if payload_hash != self.clipboard_manager.previous_clipboard_hash: + if not valid_size(content_size_bytes): + content_size_bytes = content_size(payload, payload_type) + plan = self._relay_recipients.get() + if plan is None: + plan = RequestManager(self.config).binary_recipients() + encrypted = bool(self.config.data["cipher_enabled"]) + # Construct only the representations required by this snapshot. + messages = [] + if plan is not None and plan[True]: + try: + raw = binary.encode_content(payload, payload_type, self.cipher_manager if encrypted else None) + except ValueError as error: + if str(error) != "Binary header too large": + raise + plan[False].extend(plan[True]) + plan[True] = [] + else: + body = binary.encode_frame(raw, payload_type, {SIZE_FIELD: content_size_bytes}, encrypted) + messages.append(("/app/clipbinary", body, {"content-type": binary.CONTENT_TYPE, + "x-clipcascade-recipients": ",".join(plan[True])}, None)) + if plan is None or plan[False]: + echo_key = None + prepared = binary.legacy_content(payload, payload_type) + if encrypted: + envelope = self.cipher_manager.encrypt(prepared) + echo_key = (payload_type, "encrypted", envelope["nonce"], envelope["tag"]) + prepared = CipherManager.encode_to_json_string(**envelope) + body = json.dumps({"payload": prepared, "type": payload_type, + "metadata": {SIZE_FIELD: content_size_bytes}}) + headers = {} if plan is None else {"x-clipcascade-recipients": ",".join(plan[False])} + messages.append((SEND_DESTINATION, body, headers, echo_key)) + for destination, body, headers, echo_key in messages: + if (not is_current() or generation != self._transfer_generation or not self.is_connected + or client is not self.client or not self.config.data.get("send_clipboard", True)): + return + if echo_key is not None: + self._sent_echoes.remember(echo_key) + try: + client.send(destination=destination, body=body, headers=headers) + except Exception: + if echo_key is not None: + self._sent_echoes.consume(echo_key) + raise + if generation == self._transfer_generation and client is self.client: + self.clipboard_manager.previous_clipboard_hash = payload_hash except Exception as e: logging.error(f"Failed to send data: {e}") - def _receive(self, frame: any) -> str: + def _receive(self, frame: any, recipient_cache=None) -> str: + # Controls must be accepted before clipboard permissions or connection-ready checks. + parsed_body = None + if not isinstance(frame.body, bytes): + try: + parsed_body = json.loads(frame.body) + if (recipient_cache or self._relay_recipients).accept(parsed_body): + return + except (ValueError, TypeError): + pass + generation = self._transfer_generation if not self.config.data.get("receive_clipboard", True): return try: if self.is_connected: - body = json.loads(frame.body) + is_binary = isinstance(frame.body, bytes) + body = binary.decode_frame(frame.body) if is_binary else parsed_body + if not allows_content(receive_limit(self.config), metadata_size(body.get("metadata"))): + logging.info("Incoming clipboard content exceeds the local size limit") + return payload = body["payload"] payload_type = body.get("type", "text") - if self.config.data["cipher_enabled"]: - payload = self.cipher_manager.decrypt( - **CipherManager.decode_from_json_string(payload) - ) + if is_binary: + if body["encrypted"] != bool(self.config.data["cipher_enabled"]): + raise ValueError("Encryption settings differ between devices") + payload = binary.decode_content(payload, payload_type, + self.cipher_manager if body["encrypted"] else None, receive_limit(self.config)) + elif self.config.data["cipher_enabled"]: + envelope = CipherManager.decode_from_json_string(payload) + if self._sent_echoes.consume((payload_type, "encrypted", envelope["nonce"], envelope["tag"])): + return + payload = self.cipher_manager.decrypt(**envelope) + del envelope - if self.clipboard_manager.has_clipboard_changed(payload): - self.clipboard_manager.base64_to_clipboard( - base64_string=payload, type_=payload_type + payload = binary.original_content(payload, payload_type) + if generation != self._transfer_generation: + return + if self.clipboard_manager.has_clipboard_changed(payload, payload_type): + self._on_local_change() + generation = self._transfer_generation + self.clipboard_manager.cancel_preparation() + self.clipboard_manager.content_to_clipboard( + content=payload, type_=payload_type, + is_current=lambda: generation == self._transfer_generation ) except json.decoder.JSONDecodeError: logging.error( @@ -159,6 +249,8 @@ def _receive(self, frame: any) -> str: logging.error(f"Failed to receive data: {e}") def disconnect(self): + self._relay_recipients.reset() + self._on_local_change() self._stop_reconnect() self.is_connected = False try: diff --git a/ClipCascade_Desktop/src/utils/binary_protocol.py b/ClipCascade_Desktop/src/utils/binary_protocol.py new file mode 100644 index 000000000..834f786ad --- /dev/null +++ b/ClipCascade_Desktop/src/utils/binary_protocol.py @@ -0,0 +1,130 @@ +"""CCB1: bounded JSON headers and opaque bytes; legacy clipboard adapters at edges.""" +import base64 +import json +import struct + +MAGIC = b"CCB1" +MAX_HEADER = 65536 +FORMAT = "ccb1" +CONTENT_TYPE = "application/octet-stream" + + +def pack(header, payload): + encoded = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + if len(encoded) > MAX_HEADER: + raise ValueError("Binary header too large") + return MAGIC + struct.pack(">I", len(encoded)) + encoded + payload + + +def unpack(data): + if not isinstance(data, bytes) or len(data) < 8 or data[:4] != MAGIC: + raise ValueError("Invalid binary frame") + size = struct.unpack(">I", data[4:8])[0] + if size > MAX_HEADER or size > len(data) - 8: + raise ValueError("Invalid binary header length") + header = json.loads(data[8:8 + size].decode("utf-8")) + if not isinstance(header, dict): + raise ValueError("Invalid binary header") + return header, data[8 + size:] + + +def original_content(payload, kind): + """Normalize legacy input once at ingress; modern adapters already return bytes.""" + if kind == "text": + return payload + if kind == "image": + return payload if isinstance(payload, bytes) else base64.b64decode("".join(payload.split()), validate=True) + if kind == "files": + if isinstance(payload, dict): + return payload + return {name: base64.b64decode("".join(value.split()), validate=True) + for name, value in json.loads(payload).items()} + raise ValueError("Unsupported content type") + + +def legacy_content(payload, kind): + if kind == "text" or isinstance(payload, str): + return payload + if kind == "image": + return base64.b64encode(payload).decode("ascii") + return json.dumps({name: base64.b64encode(value).decode("ascii") for name, value in payload.items()}) + + +def content_parts(payload, kind): + payload = original_content(payload, kind) + if kind == "text": + return {"type": kind, "files": None}, [payload.encode("utf-8")] + if kind == "image": + return {"type": kind, "files": None}, [payload] + return {"type": kind, "files": [[name, len(value)] for name, value in payload.items()]}, list(payload.values()) + + +def encode_content(payload, kind, cipher=None, on_raw_size=lambda size: None): + header, parts = content_parts(payload, kind) + # One allocation for the complete plaintext, without a second joined file body. + prefix = pack(header, b"") + raw = b"".join([prefix, *parts]) + # Kind and file boundaries are inside the authenticated plaintext. + on_raw_size(len(raw)) + if cipher is not None: + envelope = cipher.encrypt_bytes(raw) + raw = bytes([len(envelope["nonce"])]) + envelope["nonce"] + envelope["tag"] + envelope["ciphertext"] + return raw + + +def decode_content(raw, kind, cipher=None, limit=None): + if limit is not None and limit >= 0 and len(raw) > limit + MAX_HEADER + 64: + raise ValueError("Binary content exceeds receive limit") + if cipher is not None: + if not raw or not 8 <= raw[0] <= 32 or len(raw) < 1 + raw[0] + 16: + raise ValueError("Invalid encrypted binary payload") + n = raw[0] + raw = cipher.decrypt_bytes(raw[1:1+n], raw[1+n+16:], raw[1+n:1+n+16]) + header, raw = unpack(raw) + if header.get("type") != kind: + raise ValueError("Content type mismatch") + if limit is not None and limit >= 0 and len(raw) > limit: + raise ValueError("Binary content exceeds receive limit") + if kind == "text": + return raw.decode("utf-8") + if kind == "image": + return raw + if kind != "files" or not isinstance(header.get("files"), list): + raise ValueError("Invalid binary content") + result, offset = {}, 0 + for entry in header["files"]: + if not isinstance(entry, list) or len(entry) != 2: + raise ValueError("Invalid file entry") + name, size = entry + if (not isinstance(name, str) or not name or name in result + or any(c in name for c in ("/", "\\", "\0")) or name in (".", "..") + or not isinstance(size, int) or isinstance(size, bool) or size < 0 or size > len(raw) - offset): + raise ValueError("Invalid file entry") + result[name] = raw[offset:offset+size] + offset += size + if offset != len(raw): + raise ValueError("Trailing binary content") + return result + + +def encode_frame(payload, kind, metadata, encrypted=False): + return pack({"type": kind, "metadata": metadata, "encrypted": encrypted}, payload) + + +def decode_frame(data): + header, payload = unpack(data) + if header.get("type") not in ("text", "image", "files") or type(header.get("encrypted")) is not bool: + raise ValueError("Invalid binary envelope") + metadata = header.get("metadata") + size = metadata.get("contentSizeBytes") if isinstance(metadata, dict) else None + if not isinstance(size, int) or isinstance(size, bool) or not 0 <= size <= 2 ** 53 - 1: + raise ValueError("Invalid binary content size") + if "totalFragments" in metadata: + total, index = metadata.get("totalFragments"), metadata.get("index") + if (type(total) is not int or type(index) is not int or not 1 <= total <= 65536 + or not 0 <= index < total or metadata.get("isFragmented") != (total > 1) + or not isinstance(metadata.get("id"), str) or not metadata["id"] or len(payload) > 15360): + raise ValueError("Invalid binary fragments") + header["payload"] = payload + header["_cc_binary"] = True + return header diff --git a/ClipCascade_Desktop/src/utils/cipher_manager.py b/ClipCascade_Desktop/src/utils/cipher_manager.py index b76157ae6..a78e5c563 100644 --- a/ClipCascade_Desktop/src/utils/cipher_manager.py +++ b/ClipCascade_Desktop/src/utils/cipher_manager.py @@ -30,16 +30,21 @@ def hash_password(self, password: str) -> bytes: ) def encrypt(self, plaintext: str) -> dict: + return self.encrypt_bytes(plaintext.encode("utf-8")) + + def encrypt_bytes(self, plaintext_bytes: bytes) -> dict: key = self.config.data["hashed_password"] - plaintext_bytes = plaintext.encode("utf-8") cipher = AES.new(key, self.mode) ciphertext, tag = cipher.encrypt_and_digest(plaintext_bytes) return {"nonce": cipher.nonce, "ciphertext": ciphertext, "tag": tag} def decrypt(self, nonce: bytes, ciphertext: bytes, tag: bytes) -> str: + return self.decrypt_bytes(nonce, ciphertext, tag).decode() + + def decrypt_bytes(self, nonce: bytes, ciphertext: bytes, tag: bytes) -> bytes: key = self.config.data["hashed_password"] cipher = AES.new(key, self.mode, nonce=nonce) - return cipher.decrypt_and_verify(ciphertext, tag).decode() + return cipher.decrypt_and_verify(ciphertext, tag) @staticmethod def encode_to_json_string(**kwargs: bytes) -> str: diff --git a/ClipCascade_Desktop/src/utils/clipboard_echoes.py b/ClipCascade_Desktop/src/utils/clipboard_echoes.py new file mode 100644 index 000000000..6600a04eb --- /dev/null +++ b/ClipCascade_Desktop/src/utils/clipboard_echoes.py @@ -0,0 +1,26 @@ +"""Recognize encrypted P2S echoes from legacy servers without storing payloads.""" +from collections import OrderedDict +from threading import Lock + + +class ClipboardEchoes: + def __init__(self): + self._pending = OrderedDict() + self._lock = Lock() + + def remember(self, key): + with self._lock: + self._pending[key] = self._pending.get(key, 0) + 1 + while len(self._pending) > 1024: + self._pending.popitem(last=False) + + def consume(self, key): + with self._lock: + count = self._pending.get(key, 0) + if not count: + return False + if count == 1: + del self._pending[key] + else: + self._pending[key] = count - 1 + return True diff --git a/ClipCascade_Desktop/src/utils/relay_recipients.py b/ClipCascade_Desktop/src/utils/relay_recipients.py new file mode 100644 index 000000000..a879c8353 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/relay_recipients.py @@ -0,0 +1,60 @@ +"""Connection-scoped recipient snapshots; no clipboard contents or encryption keys.""" +import re +import time +from threading import Lock + +CONTROL = "relay-recipients" +TTL_SECONDS = 60 + + +def split_recipients(snapshot): + peers = snapshot.get("recipients") + if snapshot.get("format") != "ccb1" or not isinstance(peers, list) or len(peers) > 256: + return None + if not all(isinstance(peer, dict) and isinstance(peer.get("id"), str) + and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", peer["id"]) + and type(peer.get("binary")) is bool for peer in peers): + return None + if len({peer["id"] for peer in peers}) != len(peers): + return None + return {flag: [peer["id"] for peer in peers if peer["binary"] == flag] for flag in (False, True)} + + +class RelayRecipients: + def __init__(self, clock=time.monotonic): + self._clock = clock + self._lock = Lock() + self.reset() + + def reset(self): + with self._lock: + self._state = None + + def accept(self, message): + """Consume reserved controls even if invalid; never treat them as clipboard data.""" + if not isinstance(message, dict) or message.get("_cc_control") != CONTROL: + return False + revision = message.get("revision") + epoch = message.get("epoch") + if (message.get("version") != 1 or type(revision) is not int or not 0 <= revision <= 2 ** 53 - 1 + or not isinstance(epoch, str) or not 1 <= len(epoch) <= 128): + return True + with self._lock: + if self._state is not None: + old_epoch, old_revision, _, _ = self._state + if epoch != old_epoch or revision <= old_revision: + return True + plan = split_recipients(message) + # Invalid/oversized snapshots invalidate the old membership, allowing safe fallback. + self._state = (epoch, revision, self._clock(), plan) + return True + + def get(self): + with self._lock: + if self._state is None: + return None + _, _, received, plan = self._state + if plan is None or self._clock() - received >= TTL_SECONDS: + return None + # Callers can adapt a representation without mutating the shared snapshot. + return {flag: list(peers) for flag, peers in plan.items()} diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py index f4d82df09..c6f2fe51a 100644 --- a/ClipCascade_Desktop/src/utils/request_manager.py +++ b/ClipCascade_Desktop/src/utils/request_manager.py @@ -76,6 +76,27 @@ def login(self) -> tuple[bool, str, dict]: logging.error(msg) return False, msg, None + def binary_recipients(self): + """An authenticated, per-transfer snapshot; old servers fall back to v1.""" + try: + response = requests.get(self.config.data["server_url"].rstrip("/") + "/clipbinary/recipients", + headers={"Cookie": self.format_cookie(self.config.data["cookie"])}, + verify=self._verify(), timeout=3) + if response.status_code != 200: + return None + plan = response.json() + peers = plan.get("recipients") + import re + if plan.get("format") != "ccb1" or not isinstance(peers, list) or len(peers) > 256: + return None + if not all(isinstance(peer, dict) and isinstance(peer.get("id"), str) + and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", peer["id"]) + and type(peer.get("binary")) is bool for peer in peers): + return None + return {flag: [peer["id"] for peer in peers if peer["binary"] == flag] for flag in (False, True)} + except (requests.RequestException, ValueError, TypeError): + return None + def maxsize(self) -> int: try: response = RequestManager.get( @@ -136,9 +157,6 @@ def get_metadata(self) -> dict: try: response = RequestManager.get( url=METADATA_URL, - headers={ - "Cookie": RequestManager.format_cookie(self.config.data["cookie"]) - }, verify=True, ) if response.status_code == 200: diff --git a/ClipCascade_Desktop/src/utils/secret_storage.py b/ClipCascade_Desktop/src/utils/secret_storage.py new file mode 100644 index 000000000..744e587ed --- /dev/null +++ b/ClipCascade_Desktop/src/utils/secret_storage.py @@ -0,0 +1,75 @@ +"""Use the platform credential store without selecting a plaintext backend.""" + +import logging +import sys +import time + +SERVICE = "ClipCascade" + +# Help for the GUI credential storage tooltip. +STORAGE_HELP = ( + "Off (default): saved login credentials are stored in the local DATA file, " + "without OS credential-store protection.\n\n" + "On: store saved login credentials in Windows Credential Manager, macOS Keychain, " + "or a Linux Secret Service keyring instead of DATA. " + "This also protects saved session credentials and encryption keys; " + "it does not change whether your password is remembered. " + "This choice is saved immediately and takes effect after your next successful manual login. " + "Closing the login form keeps the existing saved credentials in their current location.\n\n" + "Linux: starting before OS login may leave the credential service unavailable. " + "A locked keyring may show an unlock popup when a desktop session is available, " + "including after automatic login.\n\n" + "macOS: an updated app may ask you to allow Keychain access again. " + "Allow access to continue using the saved login." +) + + +def is_service_unavailable(error): + """Recognize availability failures, not evidence that startup is in progress.""" + seen = set() + while error is not None and id(error) not in seen: + seen.add(id(error)) + if sys.platform == "win32" and getattr(error, "winerror", None) == 1312: + return True # ERROR_NO_SUCH_LOGON_SESSION + if sys.platform.startswith("linux"): + try: + from secretstorage.exceptions import SecretServiceNotAvailableException + except ImportError: + pass + else: + if isinstance(error, SecretServiceNotAvailableException): + return True + # keyring's priority check raises this without a chained exception. + if isinstance(error, RuntimeError) and str(error) == ( + "The Secret Service daemon is neither running nor activatable through D-Bus" + ): + return True + error = error.__cause__ or error.__context__ + return False + + +def get_password(ref): + """Allow normal OS prompts; retry only recognized service availability errors.""" + delays = (1, 2) + for attempt in range(len(delays) + 1): + try: + return backend().get_password(SERVICE, ref) + except Exception as error: + if attempt == len(delays) or not is_service_unavailable(error): + raise + logging.info("Credential service unavailable; retrying saved credential access") + time.sleep(delays[attempt]) + + +def backend(): + if sys.platform == "darwin": + from keyring.backends.macOS import Keyring + elif sys.platform == "win32": + from keyring.backends.Windows import WinVaultKeyring as Keyring + else: + from keyring.backends.SecretService import Keyring + + store = Keyring() + if store.priority <= 0: + raise RuntimeError("Platform credential store is unavailable") + return store diff --git a/ClipCascade_Desktop/src/utils/transfer_flow.py b/ClipCascade_Desktop/src/utils/transfer_flow.py new file mode 100644 index 000000000..d6a8cc69e --- /dev/null +++ b/ClipCascade_Desktop/src/utils/transfer_flow.py @@ -0,0 +1,102 @@ +"""Flow control for the legacy P2P wire format.""" +import asyncio +import json +import logging +import time + + +class TransferSuperseded(Exception): + pass + + +async def wait_for_capacity(channel, size, check_current, limit=1024 * 1024, timeout=30): + """Bound queued bytes per peer and stop waiting on a stalled connection.""" + previous = channel.bufferedAmount + last_progress = time.monotonic() + while True: + check_current() + if channel.readyState != "open": + raise ConnectionError("Data channel closed during transfer") + buffered = channel.bufferedAmount + if buffered + size <= limit: + return + if buffered < previous: + last_progress = time.monotonic() + if time.monotonic() - last_progress >= timeout: + raise TimeoutError("Data channel stopped draining") + previous = buffered + await asyncio.sleep(0.02) + + +async def send_shared_fragments(fragments, recipients, metadata, payload_type, + check_current, on_progress=lambda *_: None, allow_peer=lambda _: True, encode=None): + """Run one sender per peer; retain only fragments still needed by a peer.""" + total = len(fragments) + positions = [0] * len(recipients) + released = 0 + canceled = False + + def check(): + if canceled: + raise TransferSuperseded() + check_current() + + def release_consumed(): + nonlocal released + oldest = min(positions, default=total) + while released < oldest: + fragments[released] = None + released += 1 + on_progress(oldest, total) + + async def send_peer(peer, channel): + nonlocal canceled + def check_peer(): + check() + if not allow_peer(channel): + raise ConnectionError("Peer receive limit changed") + try: + for index in range(total): + check() + if not allow_peer(channel): + return False + body = (encode(fragments[index], {**metadata, "index": index}) if encode else + json.dumps({"payload": fragments[index], "type": payload_type, + "metadata": {**metadata, "index": index}})) + await wait_for_capacity(channel, len(body) if isinstance(body, bytes) else len(body.encode("utf-8")), check_peer) + check() + if not allow_peer(channel): + return False + channel.send(body) + positions[peer] = index + 1 + release_consumed() + await asyncio.sleep(0) + return True + except TransferSuperseded: + canceled = True + raise + except asyncio.CancelledError: + raise + except Exception as error: + # A closed/stalled peer must not stop the other recipients. + logging.warning("P2P %s %s: peer %s send failed at fragment %s/%s: %s", + payload_type, metadata.get("id"), peer, positions[peer], total, error) + return False + finally: + positions[peer] = total + release_consumed() + + tasks = [asyncio.create_task(send_peer(peer, channel)) + for peer, channel in enumerate(recipients)] + try: + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, TransferSuperseded): + raise result + return sum(result is True for result in results) + finally: + for task in tasks: + if not task.done(): + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + fragments.clear() diff --git a/ClipCascade_Desktop/src/utils/transfer_limits.py b/ClipCascade_Desktop/src/utils/transfer_limits.py new file mode 100644 index 000000000..421820172 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/transfer_limits.py @@ -0,0 +1,69 @@ +"""Optional original-content sizes and receive capabilities for legacy transports.""" +import json +import re + +MAX_SIZE = 2 ** 53 - 1 +SIZE_FIELD = "contentSizeBytes" +LIMIT_HEADER = "x-clipcascade-receive-limit" + + +def valid_size(value): + return isinstance(value, int) and not isinstance(value, bool) and 0 <= value <= MAX_SIZE + + +def receive_limit(config): + value = config.data.get("max_clipboard_size_local_limit_bytes") + return value if valid_size(value) else None + + +def receive_limit_header(config): + value = receive_limit(config) + return "unlimited" if value is None else str(value) + + +def capability_message(config): + return json.dumps({"_cc_keepalive": True, + "capabilities": {"version": 1, "transferFormats": ["ccb1"], "receiveLimitBytes": receive_limit(config)}}) + + +def read_capability(message, limits, peer): + capability = message.get("capabilities") + if not isinstance(capability, dict) or capability.get("version") != 1: + return + if "receiveLimitBytes" not in capability: + return + value = capability["receiveLimitBytes"] + if value is None or valid_size(value): + limits[peer] = value + + +def allows_content(limit, size): + return not valid_size(limit) or not valid_size(size) or size <= limit + + +def metadata_size(metadata): + value = metadata.get(SIZE_FIELD) if isinstance(metadata, dict) else None + return value if valid_size(value) else None + + +def base64_size(value): + """Decoded length without allocating the decoded content.""" + length = len(value) - sum(len(match[0]) for match in re.finditer(r"\s+", value)) + end = len(value) - 1 + padding = 0 + while end >= 0 and (value[end].isspace() or value[end] == "="): + padding += value[end] == "=" + end -= 1 + return (length // 4) * 3 - padding + + +def content_size(payload, kind): + """Original bytes, excluding Base64, JSON, and encryption overhead.""" + if kind == "text": + return len(payload.encode("utf-8")) + if kind == "image": + return len(payload) if isinstance(payload, bytes) else base64_size(payload) + if kind == "files": + return (sum(len(value) for value in payload.values()) if isinstance(payload, dict) else + sum(base64_size(value) for value in json.loads(payload).values())) + return None diff --git a/ClipCascade_Mobile/src/App.js b/ClipCascade_Mobile/src/App.js index 3bd126559..6d9f46415 100644 --- a/ClipCascade_Mobile/src/App.js +++ b/ClipCascade_Mobile/src/App.js @@ -42,7 +42,6 @@ import { DOMParser } from 'react-native-html-parser'; import { setDataInAsyncStorage, getDataFromAsyncStorage, - getMultipleDataFromAsyncStorage, clearAsyncStorage, } from './AsyncStorageManagement'; import StartForegroundService from './StartForegroundService'; @@ -65,6 +64,7 @@ import { import { DEVICE_PREFERENCE_FIELDS, isValidClipboardLimit, + saveClipboardLimit, saveDevicePreference, waitForPreferenceWrites, } from './DevicePreferences'; @@ -73,49 +73,74 @@ const isCancel = error => isErrorWithCode(error) && error.code === errorCodes.OPERATION_CANCELED; /* - * These files are part of the ClipCascade project. + * ClipCascade files to copy into a React Native 0.80.2 project named ClipCascade. + * Paths are relative to ClipCascade_Mobile/src; copy folder entries recursively. + * Keep the generated project boilerplate and local debug signing setup. + * Merge the listed build configuration with your local signing configuration. + * Apply the non-secret settings from android/gradle.properties.ci to the new + * project's gradle.properties, including newArchEnabled=false and hermesEnabled=true. + * Install package.json dependencies after copying. Private signing files are excluded. * - * (file) android\app\src\main\java\com\clipcascade\AsyncStorageBridge.kt - * (file) android\app\src\main\java\com\clipcascade\BootReceiver.kt - * (file) android\app\src\main\java\com\clipcascade\ClipboardContent.kt - * (file) android\app\src\main\java\com\clipcascade\ClipboardFloatingActivity.kt - * (file) android\app\src\main\java\com\clipcascade\ClipboardListenerModule.kt - * (file) android\app\src\main\java\com\clipcascade\ClipboardListenerPackage.kt - * (file) android\app\src\main\java\com\clipcascade\HeadlessTaskService.kt - * (file) android\app\src\main\java\com\clipcascade\MainActivity.kt - * (file) android\app\src\main\java\com\clipcascade\MainApplication.kt - * (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\PermissionGrantService.kt - * (file) android\app\src\main\java\com\clipcascade\ShizukuPermissionsModule.kt - * (file) android\app\src\main\aidl\com\clipcascade\IPermissionGrantService.aidl - * (file) android\app\src\main\java\com\clipcascade\PendingShares.kt - * (file) android\app\src\main\java\com\clipcascade\ScheduleService.kt - * (file) android\app\src\main\java\com\clipcascade\ServiceRunRegistry.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 - * (file) android\app\my-upload-key.keystore - * (file) android\gradle.properties + * Shared JavaScript app code + * (file) App.js + * (file) AppVersion.js * (file) AsyncStorageManagement.js + * (file) BinaryProtocol.js + * (file) ClipboardLimit.js + * (file) ClipboardPreparation.js + * (file) DevicePreferences.js + * (file) DownloadDirectory.js + * (file) DownloadNotifications.js + * (file) FileTransfer.js * (file) HeadlessTask.js - * (file) index.js - * (file) StartForegroundService.js * (file) LoginSession.js + * (file) RelayRecipients.js * (file) ServerDiscovery.js * (file) ServiceLifecycle.js * (file) ServiceStatus.js + * (file) StartForegroundService.js * (file) StompShutdown.js - * (file) WebSocketStatus.js - * (file) DevicePreferences.js - * (file) DownloadDirectory.js - * (file) DownloadNotifications.js - * (file) ClipboardLimit.js - * (file) FileTransfer.js - * (file) AppVersion.js + * (file) StompSocket.js + * (file) TransferLimits.js * (file) UpdateChecker.js + * (file) WebSocketStatus.js + * (file) index.js + * + * Android native code + * (file) android/app/src/main/aidl/com/clipcascade/IPermissionGrantService.aidl + * (file) android/app/src/main/java/com/clipcascade/AsyncStorageBridge.kt + * (file) android/app/src/main/java/com/clipcascade/BootReceiver.kt + * (file) android/app/src/main/java/com/clipcascade/ClipboardContent.kt + * (file) android/app/src/main/java/com/clipcascade/ClipboardFloatingActivity.kt + * (file) android/app/src/main/java/com/clipcascade/ClipboardListenerModule.kt + * (file) android/app/src/main/java/com/clipcascade/ClipboardListenerPackage.kt + * (file) android/app/src/main/java/com/clipcascade/ClipboardObservations.kt + * (file) android/app/src/main/java/com/clipcascade/HeadlessTaskService.kt + * (file) android/app/src/main/java/com/clipcascade/MainActivity.kt + * (file) android/app/src/main/java/com/clipcascade/MainApplication.kt + * (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/NativeStompSocket.kt + * (file) android/app/src/main/java/com/clipcascade/PendingShares.kt + * (file) android/app/src/main/java/com/clipcascade/PermissionGrantService.kt + * (file) android/app/src/main/java/com/clipcascade/ProtectedStorage.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/java/com/clipcascade/ServiceRunRegistry.kt + * (file) android/app/src/main/java/com/clipcascade/ShizukuPermissionsModule.kt + * + * App identity and dependencies + * (file) package.json + * (file) app.json + * + * Android resources and build configuration + * (file) android/app/src/main/AndroidManifest.xml + * (folder) android/app/src/main/res/ + * (file) android/app/build.gradle + * (file) android/build.gradle + * (file) android/gradle.properties.ci + * */ // Main App @@ -451,6 +476,11 @@ export default function App() { data_s[key] = value; } } + if (data_s.save_password === 'true' && data_s.cipher_enabled !== 'true') { + await getDataFromAsyncStorage('password'); // Migrate a saved login even when the cookie is still valid. + } else { + await setDataInAsyncStorage('password', ''); + } return data_s; } catch (e) { throw e; @@ -517,7 +547,10 @@ export default function App() { // get data from async storage and initialize data hook let data_s = await getAsyncStorage(); setData(data_s); - setClipboardLimitDraft(formatClipboardLimit(data_s.max_clipboard_size_local_limit_bytes)); + const clipboardLimitDisplay = await getDataFromAsyncStorage('clipboard_limit_display'); + setClipboardLimitDraft( + formatClipboardLimit(data_s.max_clipboard_size_local_limit_bytes, clipboardLimitDisplay), + ); setClipboardLimitError(''); setDownloadDirectory((await getDownloadDirectory()) || ''); await NativeModules.ServerTlsModule.configure( @@ -736,6 +769,8 @@ export default function App() { ]; } data_s.hashed_password = hashResult[1].toString('base64'); + } else { + data_s.hashed_password = ''; } return [true, 'Login successful: ' + authentication.status, data_s]; @@ -794,6 +829,8 @@ export default function App() { } await setDataInAsyncStorage('csrf_token', ''); + await setDataInAsyncStorage('hashed_password', ''); + setData(previous => ({...previous, csrf_token: '', hashed_password: ''})); setEnableWSPage(false); setEnableLoginPage(true); setLoginStatusMessage(''); @@ -946,11 +983,7 @@ export default function App() { ...prevData, [field]: value, })); - if ( - DEVICE_PREFERENCE_FIELDS.has(field) && - (field !== 'max_clipboard_size_local_limit_bytes' || - isValidClipboardLimit(value)) - ) { + if (DEVICE_PREFERENCE_FIELDS.has(field)) { saveDevicePreference(field, value) .then(async () => { if (field === 'enable_periodic_checks') { @@ -990,11 +1023,17 @@ export default function App() { }; const handleClipboardLimitChange = draft => { + if (loginInProgressRef.current) { + return; + } setClipboardLimitDraft(draft); try { const bytes = parseClipboardLimit(draft.amount, draft.unit); setClipboardLimitError(''); - handleInputChange('max_clipboard_size_local_limit_bytes', bytes); + setData(previous => ({...previous, max_clipboard_size_local_limit_bytes: bytes})); + saveClipboardLimit(bytes, draft).catch(error => { + setLoginStatusMessage('❌ Unable to save preference: ' + error); + }); } catch (error) { setClipboardLimitError(error.message); } @@ -1147,6 +1186,9 @@ export default function App() { // State to manage websocket page message const [wsPageMessage, setWsPageMessage] = useState(''); + const wsDetailMessage = wsPageMessage.startsWith('✅ Connected - ⏳ Loading ') + ? wsPageMessage.slice('✅ Connected - '.length) + : wsPageMessage === '✅ Connected' ? '' : wsPageMessage; const syncConnected = wsIsRunning === 'true' && wsPageMessage.includes('✅ Connected'); const syncStatusColor = isChangingSync ? (isDark ? '#fbbf24' : '#b45309') @@ -1694,8 +1736,8 @@ export default function App() { ? 'Clipboard sync is running in the background.' : 'Start sync to share clipboard content with your devices.'} - {wsPageMessage !== '' && wsPageMessage !== '✅ Connected' && ( - {wsPageMessage} + {wsDetailMessage !== '' && ( + {wsDetailMessage} )} {wsPageP2PMessage !== '' && ( {wsPageP2PMessage} diff --git a/ClipCascade_Mobile/src/AsyncStorageManagement.js b/ClipCascade_Mobile/src/AsyncStorageManagement.js index 76347efe6..b534aa2d1 100644 --- a/ClipCascade_Mobile/src/AsyncStorageManagement.js +++ b/ClipCascade_Mobile/src/AsyncStorageManagement.js @@ -1,7 +1,14 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; // persistent storage +import {NativeModules} from 'react-native'; + +const protectedKeys = new Set(['password', 'hashed_password', 'csrf_token']); // Save data in async storage export const setDataInAsyncStorage = async (key, value) => { + if (protectedKeys.has(key)) { + await NativeModules.NativeBridgeModule.writeProtectedValue(key, value ?? ''); + return; + } try { await AsyncStorage.setItem(key, JSON.stringify(value)); } catch (e) { @@ -11,6 +18,9 @@ export const setDataInAsyncStorage = async (key, value) => { // Retrieve data from async storage export const getDataFromAsyncStorage = async key => { + if (protectedKeys.has(key)) { + return NativeModules.NativeBridgeModule.readProtectedValue(key); + } try { const value = await AsyncStorage.getItem(key); return value === null ? null : JSON.parse(value); @@ -26,11 +36,14 @@ export const getDataFromAsyncStorage = async key => { */ export const getMultipleDataFromAsyncStorage = async keys => { try { - const stores = await AsyncStorage.multiGet(keys); + const stores = await AsyncStorage.multiGet(keys.filter(key => !protectedKeys.has(key))); const result = {}; stores.forEach(([key, raw]) => { result[key] = raw != null ? JSON.parse(raw) : null; }); + for (const key of keys.filter(key => protectedKeys.has(key))) { + result[key] = await getDataFromAsyncStorage(key); + } return result; } catch (e) { throw e; diff --git a/ClipCascade_Mobile/src/BinaryProtocol.js b/ClipCascade_Mobile/src/BinaryProtocol.js new file mode 100644 index 000000000..573745b70 --- /dev/null +++ b/ClipCascade_Mobile/src/BinaryProtocol.js @@ -0,0 +1,135 @@ +/* CCB1 codec. Keep the browser copy byte-for-byte identical (tested). */ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('buffer').Buffer); + else root.ClipCascadeBinary = factory(null); +})(typeof globalThis !== 'undefined' ? globalThis : this, function(NodeBuffer) { + 'use strict'; + const FORMAT = 'ccb1'; + const CONTENT_TYPE = 'application/octet-stream'; + const bytes = value => value instanceof Uint8Array ? value : + ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value); + const utf8 = value => new TextEncoder().encode(value); + const text = value => new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(value); + function concat(parts) { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { result.set(part, offset); offset += part.length; } + return result; + } + function fromBase64(value) { + if (typeof value !== 'string') throw new Error('Invalid Base64'); + // Android's existing file reader uses Base64.DEFAULT (76 columns plus LF). + value = value.replace(/\s+/g, ''); + if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) throw new Error('Invalid Base64'); + const padding = value.indexOf('='); + if (padding >= 0 && (padding < value.length - 2 || !/^={1,2}$/.test(value.slice(padding)))) throw new Error('Invalid Base64'); + return NodeBuffer ? new Uint8Array(NodeBuffer.from(value, 'base64')) : Uint8Array.from(atob(value), c => c.charCodeAt(0)); + } + function toBase64(value) { + if (NodeBuffer) return NodeBuffer.from(value).toString('base64'); + let result = ''; + for (let i = 0; i < value.length; i += 8192) result += String.fromCharCode(...value.subarray(i, i + 8192)); + return btoa(result); + } + function pack(header, payload) { + const encoded = utf8(JSON.stringify(header)); + if (encoded.length > 65536) throw new Error('Binary header too large'); + const prefix = new Uint8Array(8); + prefix.set([67, 67, 66, 49]); + new DataView(prefix.buffer).setUint32(4, encoded.length); + return concat([prefix, encoded, bytes(payload)]); + } + function unpack(value) { + const data = bytes(value); + if (data.length < 8 || data[0] !== 67 || data[1] !== 67 || data[2] !== 66 || data[3] !== 49) throw new Error('Invalid binary frame'); + const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(4); + if (length > 65536 || length > data.length - 8) throw new Error('Invalid binary header length'); + const header = JSON.parse(text(data.subarray(8, 8 + length))); + if (!header || typeof header !== 'object' || Array.isArray(header)) throw new Error('Invalid binary header'); + return [header, data.subarray(8 + length)]; + } + function originalContent(payload, kind) { + if (kind === 'text') return payload; + if (kind === 'image') return typeof payload === 'string' ? fromBase64(payload) : bytes(payload); + if (kind !== 'files') throw new Error('Unsupported content type'); + return typeof payload === 'string' ? Object.fromEntries(Object.entries(JSON.parse(payload)) + .map(([name, value]) => [name, fromBase64(value)])) : payload; + } + function legacyContent(payload, kind) { + if (kind === 'text' || typeof payload === 'string') return payload; + if (kind === 'image') return toBase64(payload); + return JSON.stringify(Object.fromEntries(Object.entries(payload).map(([name, value]) => [name, toBase64(value)]))); + } + function contentParts(payload, kind) { + payload = originalContent(payload, kind); + if (kind === 'text') return [{type: kind, files: null}, [utf8(payload)]]; + if (kind === 'image') return [{type: kind, files: null}, [payload]]; + return [{type: kind, files: Object.entries(payload).map(([name, value]) => [name, value.length])}, Object.values(payload)]; + } + async function encodeContent(payload, kind, encrypt = null, onRawSize = () => {}, checkCurrent = () => {}) { + const [header, parts] = contentParts(payload, kind); + const prefix = pack(header, new Uint8Array(0)); + const raw = new Uint8Array(prefix.length + parts.reduce((sum, part) => sum + part.length, 0)); + raw.set(prefix); + let position = prefix.length; + for (const part of parts) { + for (let offset = 0; offset < part.length; offset += 1024 * 1024) { + checkCurrent(); + const block = part.subarray(offset, offset + 1024 * 1024); + raw.set(block, position); + position += block.length; + if (raw.length > 1024 * 1024) await new Promise(resolve => setTimeout(resolve, 0)); + } + } + checkCurrent(); + onRawSize(raw.length); + return encrypt ? bytes(await encrypt(raw)) : raw; + } + async function decodeContent(raw, kind, decrypt = null, limit = null, original = false) { + if (Number.isSafeInteger(limit) && limit >= 0 && raw.length > limit + 65536 + 64) throw new Error('Binary content exceeds receive limit'); + if (decrypt) raw = await decrypt(bytes(raw)); + const [header, content] = unpack(raw); + if (header.type !== kind) throw new Error('Content type mismatch'); + if (Number.isSafeInteger(limit) && limit >= 0 && content.length > limit) throw new Error('Binary content exceeds receive limit'); + if (kind === 'text') return text(content); + if (kind === 'image') return original ? content : toBase64(content); + if (kind !== 'files' || !Array.isArray(header.files)) throw new Error('Invalid binary content'); + const result = Object.create(null); + let offset = 0; + for (const entry of header.files) { + if (!Array.isArray(entry) || entry.length !== 2) throw new Error('Invalid file entry'); + const [name, length] = entry; + if (typeof name !== 'string' || !name || name === '.' || name === '..' || /[/\\\0]/.test(name) || Object.prototype.hasOwnProperty.call(result, name) || !Number.isSafeInteger(length) || length < 0 || length > content.length - offset) throw new Error('Invalid file entry'); + result[name] = original ? content.subarray(offset, offset + length) : toBase64(content.subarray(offset, offset + length)); + offset += length; + } + if (offset !== content.length) throw new Error('Trailing binary content'); + return original ? result : JSON.stringify(result); + } + const encodeFrame = (payload, kind, metadata, encrypted = false) => pack({type: kind, metadata, encrypted}, payload); + function decodeFrame(data) { + const [header, payload] = unpack(data); + if (!['text', 'image', 'files'].includes(header.type) || typeof header.encrypted !== 'boolean') throw new Error('Invalid binary envelope'); + const meta = header.metadata; + if (!Number.isSafeInteger(meta?.contentSizeBytes) || meta.contentSizeBytes < 0) throw new Error('Invalid binary content size'); + if (Object.prototype.hasOwnProperty.call(meta, 'totalFragments')) { + if (!Number.isSafeInteger(meta.totalFragments) || !Number.isSafeInteger(meta.index) + || meta.totalFragments < 1 || meta.totalFragments > 65536 || meta.index < 0 || meta.index >= meta.totalFragments + || meta.isFragmented !== (meta.totalFragments > 1) || typeof meta.id !== 'string' || !meta.id + || payload.length > 15360) throw new Error('Invalid binary fragments'); + } + return {...header, payload, _cc_binary: true}; + } + function fragments(data, size = 15360) { + const result = []; + for (let i = 0; i < data.length; i += size) result.push(data.subarray(i, i + size)); + return result.length ? result : [new Uint8Array(0)]; + } + function recipients(plan) { + if (plan?.format !== FORMAT || !Array.isArray(plan.recipients) || plan.recipients.length > 256) return null; + if (!plan.recipients.every(peer => typeof peer.id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(peer.id) && typeof peer.binary === 'boolean')) return null; + if (new Set(plan.recipients.map(peer => peer.id)).size !== plan.recipients.length) return null; + return {binary: plan.recipients.filter(p => p.binary).map(p => p.id), legacy: plan.recipients.filter(p => !p.binary).map(p => p.id)}; + } + return {FORMAT, CONTENT_TYPE, bytes, concat, fromBase64, toBase64, pack, unpack, encodeContent, decodeContent, encodeFrame, decodeFrame, fragments, recipients, originalContent, legacyContent, contentParts}; +}); diff --git a/ClipCascade_Mobile/src/ClipboardLimit.js b/ClipCascade_Mobile/src/ClipboardLimit.js index 5a524f2ab..cae73a6ba 100644 --- a/ClipCascade_Mobile/src/ClipboardLimit.js +++ b/ClipCascade_Mobile/src/ClipboardLimit.js @@ -17,8 +17,16 @@ export const parseClipboardLimit = (amount, unit) => { return String(bytes); }; -// Preserve the original byte value and blank-input behavior on every load. -export const formatClipboardLimit = value => ({ - amount: value == null ? '' : String(value), - unit: 'Bytes', -}); +export const formatClipboardLimit = (value, display) => { + const bytes = value == null ? '' : String(value); + if (typeof display?.amount === 'string' && typeof display.unit === 'string') { + try { + if (parseClipboardLimit(display.amount, display.unit) === bytes) { + return display; + } + } catch { + // Invalid saved display values fall back to Bytes. + } + } + return {amount: bytes, unit: 'Bytes'}; +}; diff --git a/ClipCascade_Mobile/src/ClipboardPreparation.js b/ClipCascade_Mobile/src/ClipboardPreparation.js new file mode 100644 index 000000000..87d3897a9 --- /dev/null +++ b/ClipCascade_Mobile/src/ClipboardPreparation.js @@ -0,0 +1,191 @@ +// A resolved Promise only yields to microtasks. Use a timer so native clipboard +// events and service liveness checks can run during large preparation loops. +export const yieldClipboardEvents = () => new Promise(resolve => setTimeout(resolve, 0)); + +export class ClipboardSuperseded extends Error { + constructor() { super('Clipboard superseded'); this.name = 'ClipboardSuperseded'; } +} + +export function isClipboardSuperseded(error) { + return error instanceof ClipboardSuperseded || error?.code === 'CLIPBOARD_SUPERSEDED'; +} + +export class ClipboardJobs { + constructor(onCancel = () => {}) { + this.generation = 0; + this.onCancel = onCancel; + } + cancel() { + this.generation++; + this.onCancel(); + return this.generation; + } + check(generation) { + if (generation !== this.generation) throw new ClipboardSuperseded(); + } +} + +export class ClipboardEchoes { + constructor() { this.pending = new Map(); } + remember(key) { + this.pending.set(key, (this.pending.get(key) || 0) + 1); + if (this.pending.size > 1024) this.pending.delete(this.pending.keys().next().value); + } + consume(key) { + const count = this.pending.get(key) || 0; + if (!count) return false; + if (count === 1) this.pending.delete(key); + else this.pending.set(key, count - 1); + return true; + } +} + +export const encryptedClipboardEchoKey = (type, envelope) => + JSON.stringify([type, 'encrypted', envelope.nonce, envelope.tag]); + +// Preserve wire order across asynchronous decrypt/clipboard calls. An actual +// local action invalidates messages that were queued before that action. +export function serializeClipboardMessages(handler, getLocalEpoch) { + let chain = Promise.resolve(); + return (...args) => { + const epoch = getLocalEpoch(); + chain = chain.catch(() => {}).then(() => { + if (epoch === getLocalEpoch()) return handler(...args); + }); + return chain; + }; +} + +// Revisions describe clipboard observations, not explicit Share actions. A +// queued event from before a share must not cancel that newer explicit action. +export class ClipboardRevisionGate { + constructor() { this.revision = 0; } + baseline(revision) { + const value = Number(revision); + if (Number.isSafeInteger(value) && value >= 0) { + this.revision = Math.max(this.revision, value); + } + } + accept(revision) { + const value = Number(revision); + if (!Number.isSafeInteger(value) || value <= this.revision) return false; + this.revision = value; + return true; + } +} + +export function acceptClipboardObservation(revisions, event) { + return revisions.accept(event.clipboardRevision) && event.clipboardEcho !== true; +} + +export function acceptsClipboardFragment(metadata, receiving, discarded) { + if (metadata == null) return true; + if (discarded.has(metadata.id)) return false; + if (typeof metadata.id !== 'string' || !metadata.id + || !Number.isSafeInteger(metadata.totalFragments) || !Number.isSafeInteger(metadata.index) + || metadata.totalFragments < 1 || metadata.index < 0 || metadata.index >= metadata.totalFragments) { + throw new Error('Invalid fragment metadata'); + } + // Old clients keep sending after cancellation. A trailing fragment must + // never allocate another buffer, even after a bounded tombstone is evicted. + return metadata.index === 0 || Object.prototype.hasOwnProperty.call(receiving, metadata.id); +} + +export async function waitForClipboardCapacity(channel, size, checkCurrent, + limit = 1024 * 1024, timeout = 30000) { + let previous = channel.bufferedAmount; + let lastProgress = Date.now(); + while (true) { + checkCurrent(); + if (channel.readyState !== 'open') throw new Error('Data channel closed during transfer'); + const buffered = channel.bufferedAmount; + if (buffered + size <= limit) return; + if (buffered < previous) lastProgress = Date.now(); + if (Date.now() - lastProgress >= timeout) throw new Error('Data channel stopped draining'); + previous = buffered; + await new Promise(resolve => setTimeout(resolve, 20)); + } +} + +export async function sendSharedClipboardFragments(fragments, recipients, metadata, payloadType, + encoder, checkCurrent, beforeFragment = async () => {}, onProgress = () => {}, allowPeer = () => true, encode = null) { + const total = fragments.length; + const positions = recipients.map(() => 0); + let released = 0; + let canceled = false; + const check = () => { + if (canceled) throw new ClipboardSuperseded(); + checkCurrent(); + }; + const releaseConsumed = () => { + const oldest = positions.reduce((minimum, position) => Math.min(minimum, position), total); + while (released < oldest) fragments[released++] = null; + onProgress(oldest, total); + }; + const sendPeer = async (channel, peer) => { + try { + for (let index = 0; index < total; index++) { + check(); + await beforeFragment(); + check(); + if (!allowPeer(channel)) return false; + const message = encode ? encode(fragments[index], {...metadata, index}) : + JSON.stringify({payload: fragments[index], type: payloadType, metadata: {...metadata, index}}); + await waitForClipboardCapacity(channel, typeof message === 'string' ? encoder.encode(message).length : message.byteLength, () => { + check(); + if (!allowPeer(channel)) throw new Error('Peer receive limit changed'); + }); + check(); + if (!allowPeer(channel)) return false; + channel.send(message); + positions[peer] = index + 1; + releaseConsumed(); + // Native clipboard/disconnect events need a turn even when all + // awaited helpers immediately resolve to microtasks. + if ((index + 1) % 64 === 0) await yieldClipboardEvents(); + } + return true; + } catch (error) { + if (isClipboardSuperseded(error)) { + canceled = true; + throw error; + } + return false; + } finally { + positions[peer] = total; + releaseConsumed(); + } + }; + try { + const results = await Promise.allSettled(recipients.map(sendPeer)); + for (const result of results) { + if (result.status === 'rejected' && isClipboardSuperseded(result.reason)) throw result.reason; + } + return results.filter(result => result.status === 'fulfilled' && result.value === true).length; + } finally { + fragments.length = 0; + } +} + +export async function fragmentClipboard(str, fragmentSize, encoder, decoder, checkCurrent = () => {}) { + if (!Number.isInteger(fragmentSize) || fragmentSize < 4) { + throw new Error('fragmentSize must be an integer of at least 4 bytes'); + } + await yieldClipboardEvents(); + checkCurrent(); + const bytes = encoder.encode(str); + const fragments = []; + let start = 0; + while (start < bytes.length) { + checkCurrent(); + let end = Math.min(start + fragmentSize, bytes.length); + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) { + end -= 1; + } + fragments.push(decoder.decode(bytes.subarray(start, end))); + start = end; + if (fragments.length % 64 === 0) await yieldClipboardEvents(); + } + checkCurrent(); + return fragments; +} diff --git a/ClipCascade_Mobile/src/DevicePreferences.js b/ClipCascade_Mobile/src/DevicePreferences.js index 279b9ac48..82d29cd84 100644 --- a/ClipCascade_Mobile/src/DevicePreferences.js +++ b/ClipCascade_Mobile/src/DevicePreferences.js @@ -1,8 +1,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import {setDataInAsyncStorage} from './AsyncStorageManagement'; export const DEVICE_PREFERENCE_FIELDS = new Set([ 'save_password', - 'max_clipboard_size_local_limit_bytes', 'relaunch_on_boot', 'enable_websocket_status_notification', 'update_notifications', @@ -24,32 +24,37 @@ export const isValidClipboardLimit = value => // Serialize changes so a slower earlier write cannot overwrite a newer toggle. let pendingWrites = Promise.resolve(); +const queuePreferenceWrite = write => { + pendingWrites = pendingWrites.catch(() => {}).then(write); + return pendingWrites; +}; + export const saveDevicePreference = (field, value) => { if (!DEVICE_PREFERENCE_FIELDS.has(field)) { return Promise.reject( new Error('Only device preferences can be autosaved'), ); } - if ( - field === 'max_clipboard_size_local_limit_bytes' - ? !isValidClipboardLimit(value) - : value !== 'true' && value !== 'false' - ) { + if (value !== 'true' && value !== 'false') { return Promise.reject(new Error('Invalid device preference')); } - pendingWrites = pendingWrites - .catch(() => {}) - .then(async () => { - const entries = [[field, JSON.stringify(value)]]; - if (field === 'save_password' && value === 'false') { - entries.push(['password', JSON.stringify('')]); - } - await AsyncStorage.multiSet(entries); - }); - return pendingWrites; + return queuePreferenceWrite(async () => { + if (field === 'save_password' && value === 'false') { + await setDataInAsyncStorage('password', ''); + } + await AsyncStorage.setItem(field, JSON.stringify(value)); + }); }; +export const saveClipboardLimit = (bytes, display) => + queuePreferenceWrite(() => + AsyncStorage.multiSet([ + ['max_clipboard_size_local_limit_bytes', JSON.stringify(bytes)], + ['clipboard_limit_display', JSON.stringify(display)], + ]), + ); + export const waitForPreferenceWrites = () => pendingWrites; // Read persisted values for every operation so a headless service sees UI changes. diff --git a/ClipCascade_Mobile/src/FileTransfer.js b/ClipCascade_Mobile/src/FileTransfer.js index 7000109d0..0a7083b0f 100644 --- a/ClipCascade_Mobile/src/FileTransfer.js +++ b/ClipCascade_Mobile/src/FileTransfer.js @@ -1,3 +1,6 @@ +import {fromBase64} from './BinaryProtocol'; +import {base64ContentSize} from './TransferLimits'; + // One URI stays one URI, even if it contains a comma. export function fileUris(content) { const uris = Array.isArray(content) ? content : [content]; @@ -7,13 +10,17 @@ export function fileUris(content) { return uris; } -export async function encodeFiles(content, bridge) { +export async function encodeFiles(content, bridge, checkCurrent = () => {}, onSize = () => {}, raw = false) { + checkCurrent(); const uris = fileUris(content); const names = await Promise.all(uris.map(uri => bridge.getFileName(uri))); + checkCurrent(); const reserved = new Set(names.map(name => name.toLowerCase())); const used = new Set(); const files = Object.create(null); + let contentSizeBytes = 0; for (let i = 0; i < uris.length; i++) { + checkCurrent(); const name = names[i]; const dot = name.lastIndexOf('.'); const stem = dot > 0 ? name.slice(0, dot) : name; @@ -26,13 +33,16 @@ export async function encodeFiles(content, bridge) { } while (reserved.has(candidate.toLowerCase()) || used.has(candidate.toLowerCase())); } used.add(candidate.toLowerCase()); - files[candidate] = await bridge.getFileAsBase64(uris[i]); + files[candidate] = raw ? await readClipboardBytes(uris[i], bridge, checkCurrent) : await bridge.getFileAsBase64(uris[i]); + checkCurrent(); + contentSizeBytes += raw ? files[candidate].length : base64ContentSize(files[candidate]); } - return JSON.stringify(files); + onSize(contentSizeBytes); + return raw ? files : JSON.stringify(files); } // Consume before attempting delivery: failures are reported, never replayed. -export async function deliverPendingShare(bridge, ready, send, onError) { +export async function deliverPendingShare(bridge, ready, send, onError, onBeginShare = () => {}) { // A connection opening during the native read must not revive a rejected share. const wasReady = ready(); const share = await bridge.takePendingShare(); @@ -44,6 +54,7 @@ export async function deliverPendingShare(bridge, ready, send, onError) { if (!wasReady || !ready()) { throw new Error('Share not sent: no connection is ready or another send is in progress. Wait until connected, then share it again.'); } + onBeginShare(share.clipboardBaselineRevision); if ((await send(share.content, share.type)) === false) { throw new Error('Share was interrupted. Share it again.'); } @@ -52,3 +63,37 @@ export async function deliverPendingShare(bridge, ready, send, onError) { } return true; } + +// Read a URI once. The classic RN bridge requires bounded Base64 chunks; +// decode each immediately, retaining bytes instead of a legacy attachment string. +export async function readClipboardBytes(uri, bridge, checkCurrent = () => {}) { + const id = await bridge.openClipboardBytes(uri); + return readClipboardStream(id, bridge, checkCurrent); +} + +export async function readClipboardStream(id, bridge, checkCurrent = () => {}) { + try { + const parts = []; + while (true) { + checkCurrent(); + const part = await bridge.readClipboardBytes(id); + checkCurrent(); + if (!part.length) break; + parts.push(fromBase64(part)); + await new Promise(resolve => setTimeout(resolve, 0)); + } + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (let index = 0; index < parts.length; index++) { + checkCurrent(); + result.set(parts[index], offset); + offset += parts[index].length; + parts[index] = null; + if (index % 32 === 0) await new Promise(resolve => setTimeout(resolve, 0)); + } + checkCurrent(); + return result; + } finally { + await bridge.closeClipboardBytes(id); + } +} diff --git a/ClipCascade_Mobile/src/RelayRecipients.js b/ClipCascade_Mobile/src/RelayRecipients.js new file mode 100644 index 000000000..86697c2c9 --- /dev/null +++ b/ClipCascade_Mobile/src/RelayRecipients.js @@ -0,0 +1,31 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('./BinaryProtocol')); + else root.ClipCascadeRelay = factory(root.ClipCascadeBinary); +})(typeof globalThis !== 'undefined' ? globalThis : this, function(binary) { +const {recipients} = binary; + +// Each WebSocket connection owns one cache. Full snapshots repair missed updates. +class RelayRecipients { + constructor(clock = () => Date.now()) { + this.clock = clock; + this.reset(); + } + reset() { this.state = null; } + accept(message) { + if (message?._cc_control !== 'relay-recipients') return false; + if (message.version !== 1 || !Number.isSafeInteger(message.revision) || message.revision < 0 + || typeof message.epoch !== 'string' || !message.epoch || message.epoch.length > 128) return true; + if (this.state && (this.state.epoch !== message.epoch || message.revision <= this.state.revision)) return true; + const plan = recipients(message); + const unique = plan && new Set([...plan.binary, ...plan.legacy]).size === plan.binary.length + plan.legacy.length; + this.state = {epoch: message.epoch, revision: message.revision, received: this.clock(), plan: unique ? plan : null}; + return true; + } + get() { + if (!this.state?.plan || this.clock() - this.state.received >= 60000) return null; + return {binary: [...this.state.plan.binary], legacy: [...this.state.plan.legacy]}; + } +} + +return {RelayRecipients}; +}); diff --git a/ClipCascade_Mobile/src/StartForegroundService.js b/ClipCascade_Mobile/src/StartForegroundService.js index 87e68bf55..04e9e4d98 100644 --- a/ClipCascade_Mobile/src/StartForegroundService.js +++ b/ClipCascade_Mobile/src/StartForegroundService.js @@ -20,9 +20,18 @@ import {checkForUpdates} from './UpdateChecker'; import {downloadDirectoryLabel} from './DownloadDirectory'; import {webSocketErrorMessage, webSocketCloseMessage} from './WebSocketStatus'; import {isClipboardSyncEnabled} from './DevicePreferences'; -import {fileUris, encodeFiles, deliverPendingShare} from './FileTransfer'; +import {readClipboardStream, readClipboardBytes, fileUris, encodeFiles, deliverPendingShare} from './FileTransfer'; import {acquireServiceRun, serviceRun} from './ServiceLifecycle'; import {deactivateStompClient} from './StompShutdown'; +import {StompSocket} from './StompSocket'; +import * as binary from './BinaryProtocol'; +import {RelayRecipients} from './RelayRecipients'; +import {normalizeReceiveLimit, receiveLimitHeader, allowsContentSize, metadataContentSize, + receiveCapabilityMessage, readReceiveCapability, base64ContentSize} from './TransferLimits'; +import {fragmentClipboard, yieldClipboardEvents, ClipboardJobs, ClipboardSuperseded, + isClipboardSuperseded, sendSharedClipboardFragments, acceptsClipboardFragment, + ClipboardRevisionGate, ClipboardEchoes, encryptedClipboardEchoKey, serializeClipboardMessages, + acceptClipboardObservation} from './ClipboardPreparation'; import { getDataFromAsyncStorage, @@ -124,6 +133,26 @@ module.exports = async (inputData = null) => { ClipboardListener.startListening(); clipboardStarted = true; } + const clipboardJobs = new ClipboardJobs(() => NativeBridgeModule.cancelClipboardReadsSync()); + const clipboardRevisions = new ClipboardRevisionGate(); + const sentEchoes = new ClipboardEchoes(); + let localClipboardEpoch = 0; + let cancelIncoming = () => {}; + const checkClipboardJob = generation => { + clipboardJobs.check(generation); + if (!run.isCurrent() || transportStopped) throw new ClipboardSuperseded(); + }; + const clearShareFailure = async generation => { + checkClipboardJob(generation); + if (!isShareTransportReady()) return; + const flags = JSON.parse(NativeBridgeModule.getFlagsSync(['wsStatusMessage'])); + const message = flags.wsStatusMessage || ''; + if (message.startsWith('❌ Share failed:') || message.startsWith('✅ Connected - ⏳ Loading ')) { + // A newer clipboard action supersedes the old share's result. + // Leave unrelated connection and clipboard errors untouched. + await setDataInAsyncStorage('wsStatusMessage', '✅ Connected'); + } + }; const textEncoder = new encoding.TextEncoder(); const textDecoder = new encoding.TextDecoder(); @@ -131,13 +160,13 @@ module.exports = async (inputData = null) => { await setDataInAsyncStorage('fileDownloadStatus', ''); let previous_clipboard_content_hash = ''; - let toggle = false; // p2s toggle let block_image_once = false; let files_in_memory = null; let websocket_status_notification_toggle = false; let p2pMsg = null; // p2p status message let stompClient = null; + let stompSocket = null; let wsSignalingClient = null; let sendClipBoardP2S = null; let sendClipBoardP2P = null; @@ -146,6 +175,7 @@ module.exports = async (inputData = null) => { let isShareTransportReady = () => false; let getP2PStatusMessage = null; let isP2PStatusMsgChanged = false; + let attachmentLoading = null; // Discover in the service too: Android can start it without the app UI. const keys = [ @@ -192,24 +222,25 @@ module.exports = async (inputData = null) => { }; const maxsize = Number(maxsizeStr); - let max_clipboard_size_local_limit_bytes = Number(maxClipboardLimitStr); - if (max_clipboard_size_local_limit_bytes === 0) { - max_clipboard_size_local_limit_bytes = maxsize; - } + // Blank/legacy zero means no local cap. Server limits are separate. + const localReceiveLimit = normalizeReceiveLimit(Number(maxClipboardLimitStr) === 0 ? null : maxClipboardLimitStr); + const max_clipboard_size_local_limit_bytes = localReceiveLimit ?? -1; // encrption - const encrypt = async plainText => { + const encrypt = async (plainText, onEncrypted = () => {}) => { try { const encryptedData = await AesGcmCrypto.encrypt( plainText, false, await getDataFromAsyncStorage('hashed_password'), ); - return JSON.stringify({ + const envelope = { nonce: Buffer.from(encryptedData.iv, 'hex').toString('base64'), ciphertext: encryptedData.content, tag: Buffer.from(encryptedData.tag, 'hex').toString('base64'), - }); + }; + onEncrypted(envelope); + return JSON.stringify(envelope); } catch (e) { throw new Error('Failed to encrypt: ' + e); } @@ -231,9 +262,75 @@ module.exports = async (inputData = null) => { } }; + // The installed AES library's binary API requires whole-payload Base64. + // Use a bounded, file-backed native cipher for CCB1 instead. + const cryptBinary = async (raw, decrypting, nonce = new Uint8Array(0), tag = new Uint8Array(0)) => { + const generation = clipboardJobs.generation; + const checkCurrent = () => checkClipboardJob(generation); + checkCurrent(); + const id = await NativeBridgeModule.beginClipboardCipher( + await getDataFromAsyncStorage('hashed_password'), decrypting, Array.from(nonce), Array.from(tag)); + try { + for (let offset = 0; offset < raw.length; offset += 32768) { + checkCurrent(); + await NativeBridgeModule.appendClipboardCipher(id, binary.toBase64(raw.subarray(offset, offset + 32768))); + await yieldClipboardEvents(); + } + checkCurrent(); + const envelope = await NativeBridgeModule.finishClipboardCipher(id); + const content = await readClipboardStream(envelope.id, NativeBridgeModule, checkCurrent); + checkCurrent(); + return decrypting ? content : binary.concat([new Uint8Array([binary.fromBase64(envelope.nonce).length]), + binary.fromBase64(envelope.nonce), binary.fromBase64(envelope.tag), content]); + } catch (error) { + checkCurrent(); + throw error; + } finally { + await NativeBridgeModule.abortClipboardCipher(id); + } + }; + const encryptBinary = raw => cryptBinary(raw, false); + const decryptBinary = raw => { + const n = raw[0]; + if (!Number.isInteger(n) || n < 8 || n > 32 || raw.length < 1 + n + 16) throw new Error('Invalid encrypted binary payload'); + return cryptBinary(raw.subarray(1 + n + 16), true, raw.subarray(1, 1 + n), raw.subarray(1 + n, 1 + n + 16)); + }; + const decodeBinary = async message => { + if (message.encrypted !== (cipher_enabled === 'true')) throw new Error('Encryption settings differ between devices'); + return binary.decodeContent(message.payload, message.type, + message.encrypted ? decryptBinary : null, localReceiveLimit, true); + }; + let relayRecipientCache = new RelayRecipients(); + const relayRecipients = async () => { + const cached = relayRecipientCache.get(); + if (cached) return cached; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(server_url.replace(/\/+$/, '') + '/clipbinary/recipients', + {credentials: 'include', signal: controller.signal, headers: {Accept: 'application/json'}}); + const fallback = response.ok ? binary.recipients(await response.json()) : null; + return relayRecipientCache.get() || fallback; + } catch (_) { return null; } + finally { clearTimeout(timer); } + }; + // hash clipboard content - const hashCB = async (input, seed = 0) => { - return String(xxHash32(input, seed)); + const hashCB = async (input, kind = 'text') => { + const generation = clipboardJobs.generation; + const [header, parts] = binary.contentParts(input, kind); + let hash = xxHash32(textEncoder.encode(JSON.stringify(header)), 0); + // Fixed chunk boundaries make the hash independent of transport fragments. + for (const part of parts) { + for (let offset = 0; offset < part.length; offset += 65536) { + hash = xxHash32(part.subarray(offset, offset + 65536), hash); + if (offset % (1024 * 1024) === 0) { + await yieldClipboardEvents(); + checkClipboardJob(generation); + } + } + } + return String(hash); }; //check if clipboard content changed @@ -242,33 +339,12 @@ module.exports = async (inputData = null) => { }; const calculateBase64DecodedLength = async base64Str => { - // Calculates the decoded byte length of a Base64-encoded string. - const n = base64Str.length; - const padding = (base64Str.match(/=/g) || []).length; - return 3 * (n / 4) - padding; + return base64ContentSize(base64Str); }; // fragment string into chunks - const fragmentString = async (str, fragmentSize) => { - if (!Number.isInteger(fragmentSize) || fragmentSize < 4) { - throw new Error( - 'fragmentSize must be an integer of at least 4 bytes', - ); - } - const bytes = textEncoder.encode(str); // convert to UTF-8 bytes - const fragments = []; - let start = 0; - while (start < bytes.length) { - let end = Math.min(start + fragmentSize, bytes.length); - // Move the boundary back to the start of any split UTF-8 character. - while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) { - end -= 1; - } - const chunk = bytes.slice(start, end); - fragments.push(textDecoder.decode(chunk)); - start = end; - } - return fragments; + const fragmentString = async (str, fragmentSize, checkCurrent) => { + return fragmentClipboard(str, fragmentSize, textEncoder, textDecoder, checkCurrent); }; // generate uuid @@ -284,8 +360,36 @@ module.exports = async (inputData = null) => { isP2PStatusMsgChanged = true; }; - // validate clipboard size - const validateClipboardSize = async (clipContent, type, direction) => { + const loadClipboardAttachment = async (content, type, generation, onSize) => { + const checkCurrent = () => checkClipboardJob(generation); + checkCurrent(); + const loading = {generation, message: type === 'files' ? 'Loading files…' : 'Loading image…'}; + attachmentLoading = loading; + const message = server_mode === 'P2P' + ? await getP2PStatusMessage() + : `✅ Connected - ⏳ ${loading.message}`; + try { + checkCurrent(); + await setDataInAsyncStorage('wsStatusMessage', message); + checkCurrent(); + return type === 'files' + ? await encodeFiles(content, NativeBridgeModule, checkCurrent, onSize, true) + : await readClipboardBytes(content, NativeBridgeModule, checkCurrent); + } finally { + if (attachmentLoading === loading) attachmentLoading = null; + if (generation === clipboardJobs.generation && run.isCurrent() && !transportStopped) { + // Preserve connection errors or newer status updates made during the read. + const flags = JSON.parse(NativeBridgeModule.getFlagsSync(['wsStatusMessage'])); + if (flags.wsStatusMessage === message) { + await setDataInAsyncStorage('wsStatusMessage', server_mode === 'P2P' + ? await getP2PStatusMessage() : '✅ Connected'); + } + if (server_mode === 'P2P') await p2pStatusMessageChanged(); + } + } + }; + + const measureClipboardContent = async (clipContent, type, direction) => { let clipContentByteLength = 0; if (type === 'text') { clipContentByteLength = Buffer.byteLength(clipContent, 'utf8'); @@ -298,9 +402,8 @@ module.exports = async (inputData = null) => { await NativeBridgeModule.getFileSize(clipContent), ); } else if (direction.toLowerCase() === 'inbound') { - clipContentByteLength = await calculateBase64DecodedLength( - clipContent, - ); + clipContentByteLength = typeof clipContent === 'string' + ? await calculateBase64DecodedLength(clipContent) : clipContent.length; } } else if (type === 'files') { if ( @@ -314,21 +417,27 @@ module.exports = async (inputData = null) => { ); } } else if (direction.toLowerCase() === 'inbound') { - let files = JSON.parse(clipContent); + let files = typeof clipContent === 'string' ? JSON.parse(clipContent) : clipContent; for (const file in files) { - clipContentByteLength += await calculateBase64DecodedLength( - files[file], - ); + clipContentByteLength += typeof files[file] === 'string' + ? await calculateBase64DecodedLength(files[file]) : files[file].length; } } } else { return false; } + return clipContentByteLength; + }; + + // Both modes use original content bytes, excluding encoding overhead. + const validateClipboardSize = async (clipContent, type, direction, measuredSize = null) => { + const clipContentByteLength = measuredSize ?? await measureClipboardContent(clipContent, type, direction); + if (!Number.isSafeInteger(clipContentByteLength) || clipContentByteLength < 0) return false; if (server_mode === 'P2S') { if ( - clipContentByteLength <= maxsize && - clipContentByteLength <= max_clipboard_size_local_limit_bytes + (maxsize < 0 || clipContentByteLength <= maxsize) && + allowsContentSize(localReceiveLimit, clipContentByteLength) ) { return true; } @@ -375,6 +484,7 @@ module.exports = async (inputData = null) => { track(async params => { try { if (params && params.content && params.type) { + if (!acceptClipboardObservation(clipboardRevisions, params)) return; await sendClipBoard(params.content, params.type); } } catch (e) { @@ -387,25 +497,35 @@ module.exports = async (inputData = null) => { ); const clearFiles = async (expensiveCall = false) => { + const generation = clipboardJobs.generation; files_in_memory = null; if (!expensiveCall) { // A finished download belongs to the previous clipboard item. // Keep active saving progress visible until that batch finishes. - if ((await getDataFromAsyncStorage('fileDownloadInProgress')) !== 'true') { + const downloadInProgress = await getDataFromAsyncStorage('fileDownloadInProgress'); + clipboardJobs.check(generation); + if (downloadInProgress !== 'true') { await setDataInAsyncStorage('fileDownloadStatus', ''); + clipboardJobs.check(generation); } await notifee.cancelNotification( 'ClipCascade_Download_Files_Notification_Id', ); + clipboardJobs.check(generation); await setDataInAsyncStorage('filesAvailableToDownload', 'false'); + clipboardJobs.check(generation); await setDataInAsyncStorage('downloadFiles', 'false'); + clipboardJobs.check(generation); await setDataInAsyncStorage('dirPath', ''); + clipboardJobs.check(generation); } }; const showFilesDownloadNotification = async files => { - const count = Object.keys(JSON.parse(files)).length; + const generation = clipboardJobs.generation; + const count = Object.keys(typeof files === 'string' ? JSON.parse(files) : files).length; await setDataInAsyncStorage('availableFileCount', String(count)); + clipboardJobs.check(generation); // Display a silent notification await notifee.displayNotification({ id: 'ClipCascade_Download_Files_Notification_Id', @@ -445,7 +565,14 @@ module.exports = async (inputData = null) => { // websocket stomp client stompClient = new Client({ brokerURL: websocket_url, + webSocketFactory: () => { + stompSocket = new StompSocket(websocket_url, NativeBridgeModule, DeviceEventEmitter); + return stompSocket; + }, + connectHeaders: {'x-clipcascade-recipient-updates': '1', 'x-clipcascade-binary': 'ccb1', 'x-clipcascade-no-self-echo': 'true', + 'x-clipcascade-receive-limit': receiveLimitHeader(localReceiveLimit)}, beforeConnect: async () => { + relayRecipientCache.reset(); if (!(await track(prepareReconnect)()) && stompClient) { // Do not await deactivate inside beforeConnect: it can wait on // this very connection attempt. The poll loop completes cleanup. @@ -456,35 +583,40 @@ module.exports = async (inputData = null) => { connectionTimeout: 5000, heartbeatIncoming: HEARTBEAT_INTERVAL, heartbeatOutgoing: 0, - forceBinaryWSFrames: true, // https://stomp-js.github.io/api-docs/latest/classes/Client.html#forceBinaryWSFrames + // The native transport sends small binary writes, preserving NUL + // bytes without one oversized React Native/OkHttp send. + forceBinaryWSFrames: false, // appendMissingNULLonIncoming: true, // https://stomp-js.github.io/api-docs/latest/classes/Client.html#appendMissingNULLonIncoming onConnect: track(async () => { + const recipientCache = relayRecipientCache = new RelayRecipients(); await setDataInAsyncStorage('wsStatusMessage', '✅ Connected'); - toggle = false; // Subscribe to a topic - stompClient.subscribe(SUBSCRIPTION_DESTINATION, track(async message => { + const receiveClipboardMessage = track(serializeClipboardMessages(async (message, parsedBody) => { + let generation = clipboardJobs.generation; try { - // Echoes still release the outbound throttle in send-only mode. - toggle = false; if (!(await isClipboardSyncEnabled('receive_clipboard'))) { return; } - await clearFiles(); - await setDataInAsyncStorage( - 'wsStatusMessage', - '✅ Connected - Subscribed', - ); - if (message && message.body) { - const body = JSON.parse(message.body); - let cb = String(body.payload); + if (message && (message.isBinaryBody || message.body)) { + const isBinary = message.headers?.['content-type']?.split(';')[0] === binary.CONTENT_TYPE; + const body = isBinary ? binary.decodeFrame(message.binaryBody) : parsedBody; + if (!allowsContentSize(localReceiveLimit, metadataContentSize(body.metadata))) { + await setDataInAsyncStorage('wsStatusMessage', '⚠️ Incoming clipboard ignored: original content exceeds the local size limit.'); + return; + } + let cb = isBinary ? body.payload : String(body.payload); const type_ = body.type ?? 'text'; //decrypt - if (cipher_enabled === 'true') { + if (isBinary) { + cb = await decodeBinary(body); + } else if (cipher_enabled === 'true') { try { - cb = await decrypt(JSON.parse(cb)); + const envelope = JSON.parse(cb); + if (sentEchoes.consume(encryptedClipboardEchoKey(type_, envelope))) return; + cb = await decrypt(envelope); } catch (error) { throw new Error( `Encryption must be enabled on all devices if enabled. JSON parsing failed: ${error.message}`, @@ -493,8 +625,14 @@ module.exports = async (inputData = null) => { } // hash clipboard content - const hcb = await hashCB(cb); + const hcb = await hashCB(cb, type_); + checkClipboardJob(generation); if (await newCB(hcb)) { + generation = clipboardJobs.cancel(); + await clearFiles(); + checkClipboardJob(generation); + await clearShareFailure(generation); + checkClipboardJob(generation); previous_clipboard_content_hash = hcb; // validate clipboard size @@ -502,17 +640,20 @@ module.exports = async (inputData = null) => { if (!(await isClipboardSyncEnabled('receive_clipboard'))) { return; } + checkClipboardJob(generation); await NativeBridgeModule.discardPendingShare(); + checkClipboardJob(generation); // set clipboard content if (type_ === 'text') { await NativeBridgeModule.copyTextToClipboard(cb); } else if (type_ === 'image') { + block_image_once = true; await NativeBridgeModule.copyBase64ImageToClipboardUsingCache( - cb, + binary.legacyContent(cb, type_), ); - block_image_once = true; } else if (type_ === 'files') { await showFilesDownloadNotification(cb); + checkClipboardJob(generation); files_in_memory = cb; await setDataInAsyncStorage( @@ -522,13 +663,27 @@ module.exports = async (inputData = null) => { } } } + await clearShareFailure(generation); } } catch (e) { + if (isClipboardSuperseded(e)) return; await setDataInAsyncStorage( 'wsStatusMessage', '❌ Inbound Error: ' + e, ); } + }, () => localClipboardEpoch)); + stompClient.subscribe(SUBSCRIPTION_DESTINATION, track(message => { + if (recipientCache !== relayRecipientCache || !stompClient?.connected) return; + // Membership controls bypass clipboard serialization and receive preferences. + let parsedBody; + if (message.headers?.['content-type']?.split(';')[0] !== binary.CONTENT_TYPE) { + try { + parsedBody = JSON.parse(message.body); + if (recipientCache.accept(parsedBody)) return; + } catch (_) { return; } + } + return receiveClipboardMessage(message, parsedBody); })); if (enable_websocket_status_notification === 'true') { @@ -545,6 +700,7 @@ module.exports = async (inputData = null) => { } }), onDisconnect: track(async () => { + relayRecipientCache.reset(); block_image_once = false; await setDataInAsyncStorage('wsStatusMessage', 'Disconnected'); }), @@ -563,6 +719,7 @@ module.exports = async (inputData = null) => { ); }), onWebSocketClose: track(async event => { + relayRecipientCache.reset(); block_image_once = false; await setDataInAsyncStorage( 'wsStatusMessage', @@ -582,17 +739,31 @@ module.exports = async (inputData = null) => { }), }); - isShareTransportReady = () => run.isCurrent() && !!stompClient?.connected && !toggle; + isShareTransportReady = () => run.isCurrent() && !!stompClient?.connected; // start websocket stomp connection stompClient.activate(); // send clipboard content P2S - sendClipBoardP2S = async (clipContent, type_ = 'text') => { + sendClipBoardP2S = async (clipContent, type_ = 'text', generation) => { + const sendingSocket = stompSocket; + const checkCurrent = () => { + checkClipboardJob(generation); + if (sendingSocket !== stompSocket || (sendingSocket && sendingSocket.readyState !== 1)) { + throw new ClipboardSuperseded(); + } + }; try { await clearFiles(); - if (!stompClient?.connected || toggle) return false; - if (stompClient && stompClient.connected && !toggle) { + checkCurrent(); + if (!stompClient?.connected) return false; + if (stompClient && stompClient.connected) { + const socket = stompSocket; + if (!socket) return false; + // Finish an already-started STOMP frame before publishing + // another. Superseded waiters must not prepare more files. + await socket.flush(); + checkCurrent(); if ( (type_ === 'image' && enable_image_sharing === 'false') || (type_ === 'files' && enable_file_sharing === 'false') @@ -600,49 +771,72 @@ module.exports = async (inputData = null) => { return true; } - if ( - await validateClipboardSize(clipContent, type_, 'Outbound') - ) { - // base64 encode - if (type_ === 'image') { - clipContent = await NativeBridgeModule.getFileAsBase64( - clipContent, - ); - } else if (type_ === 'files') { - clipContent = await encodeFiles(clipContent, NativeBridgeModule); + let contentSizeBytes = await measureClipboardContent(clipContent, type_, 'Outbound'); + if (await validateClipboardSize(clipContent, type_, 'Outbound', contentSizeBytes)) { + checkCurrent(); + const plan = await relayRecipients(); + checkCurrent(); + // Load original bytes once for this recipient snapshot. + if (type_ === 'image' || type_ === 'files') { + clipContent = await loadClipboardAttachment(clipContent, type_, generation, size => { contentSizeBytes = size; }); } - // clipboad content hash - const hcb = await hashCB(clipContent); + checkCurrent(); + if (type_ === 'image') contentSizeBytes = clipContent.length; + if (!(await validateClipboardSize(clipContent, type_, 'Outbound', contentSizeBytes))) return true; + checkCurrent(); + // clipboard content hash + const hcb = await hashCB(clipContent, type_); + // Legacy servers still echo. Plaintext hashes cannot tell + // an echo apart from another device's genuine resend. + let echoKey = null; + checkCurrent(); if (await newCB(hcb)) { if (block_image_once) { block_image_once = false; } else { - toggle = true; - if (cipher_enabled === 'true') { - //ecrypt - clipContent = await encrypt(clipContent); + checkCurrent(); + const encrypted = cipher_enabled === 'true'; + const messages = []; + if (plan?.binary.length) { + try { + const raw = await binary.encodeContent(clipContent, type_, encrypted ? encryptBinary : null, undefined, checkCurrent); + checkCurrent(); + messages.push({destination: '/app/clipbinary', + headers: {'content-type': binary.CONTENT_TYPE, 'x-clipcascade-recipients': plan.binary.join(',')}, + binaryBody: binary.encodeFrame(raw, type_, {contentSizeBytes}, encrypted)}); + } catch (error) { + if (error.message !== 'Binary header too large') throw error; + plan.legacy.push(...plan.binary); + plan.binary = []; + } } - - await setDataInAsyncStorage( - 'wsStatusMessage', - '✅ Connected - Broadcasting', - ); - - if (!(await isClipboardSyncEnabled('send_clipboard'))) { - toggle = false; - return true; + if (!plan || plan.legacy.length) { + let prepared = binary.legacyContent(clipContent, type_); + if (encrypted) prepared = await encrypt(prepared, envelope => { echoKey = encryptedClipboardEchoKey(type_, envelope); }); + messages.push({destination: SEND_DESTINATION, + headers: plan ? {'x-clipcascade-recipients': plan.legacy.join(',')} : {}, + body: JSON.stringify({payload: String(prepared), type: type_, metadata: {contentSizeBytes}})}); } - if (!run.isCurrent() || transportStopped) return false; - // send - stompClient.publish({ - destination: SEND_DESTINATION, - body: JSON.stringify({ - payload: String(clipContent), - type: type_, - }), - }); + await setDataInAsyncStorage('wsStatusMessage', '✅ Connected'); + for (const message of messages) { + checkCurrent(); + if (!(await isClipboardSyncEnabled('send_clipboard'))) return true; + if (!run.isCurrent() || transportStopped) return false; + if (message.body && echoKey !== null) sentEchoes.remember(echoKey); + try { stompClient.publish(message); } + catch (error) { + if (message.body && echoKey !== null) sentEchoes.consume(echoKey); + throw error; + } + await socket.flush(); + } + // A frame already submitted can still be echoed by an + // older server, even if newer clipboard work supersedes + // us while the transport drains. Keep its nonce tracked. + await socket.flush(); + checkCurrent(); previous_clipboard_content_hash = hcb; } } @@ -650,7 +844,7 @@ module.exports = async (inputData = null) => { } return true; } catch (e) { - toggle = false; + if (isClipboardSuperseded(e)) throw e; block_image_once = false; throw e; } @@ -658,7 +852,11 @@ module.exports = async (inputData = null) => { // stop events and connection P2S stopServicesP2S = async () => { + relayRecipientCache.reset(); transportStopped = true; + clipboardJobs.cancel(); + cancelIncoming(); + files_in_memory = null; // 1) Stop clipboard listening try { if (!restarting) await stopClipboard(); @@ -698,7 +896,8 @@ module.exports = async (inputData = null) => { let myPeerId = null; // Your assigned peer ID from server let peers = new Set(); // Current set of known peer IDs in the "room" let peerConnections = {}; // Map: peerId -> RTCPeerConnection - let dataChannels = {}; // Map: peerId -> RTCDataChannel + let dataChannels = {}; + const peerReceiveLimits = new Map(); // Map: peerId -> original-content byte limit (null means unlimited) isShareTransportReady = () => run.isCurrent() && Object.values(dataChannels).some(channel => channel.readyState === 'open'); let liveConnectionsCount = 0; // Track open DataChannels let pendingPeerList = null; @@ -710,10 +909,16 @@ module.exports = async (inputData = null) => { const peerConnectionDeadlines = {}; const pendingIce = new WeakMap(); const dataChannelHeartbeatTimers = {}; - const P2P_DC_KEEPALIVE_JSON = JSON.stringify({ _cc_keepalive: true }); + const P2P_DC_KEEPALIVE_JSON = receiveCapabilityMessage(localReceiveLimit); // Fragment variables let sendingFragmentId = ''; + const discardedFragments = new Map(); + const discardFragment = id => { + discardedFragments.delete(id); + discardedFragments.set(id, true); + if (discardedFragments.size > 1024) discardedFragments.delete(discardedFragments.keys().next().value); + }; let receivingFragments = {}; // Map: fragmentId -> array of strings (ordered) let sendingFragmentStats = null; let receivingFragmentStats = null; @@ -721,6 +926,9 @@ module.exports = async (inputData = null) => { getP2PStatusMessage = async () => { let msg = '📊'; msg += ` Peers: ${liveConnectionsCount}`; + if (attachmentLoading?.generation === clipboardJobs.generation) { + msg += ` | ${attachmentLoading.message}`; + } if (sendingFragmentStats != null) { msg += ` | Sending: ${sendingFragmentStats}`; } @@ -739,9 +947,29 @@ module.exports = async (inputData = null) => { await resetP2PMsg(); }; - const resetReceivingFragments = async () => { + const clearReceiving = () => { + Object.keys(receivingFragments).forEach(discardFragment); receivingFragments = {}; receivingFragmentStats = null; + }; + cancelIncoming = () => { + sendingFragmentId = ''; + sendingFragmentStats = null; + clearReceiving(); + }; + const dropPeerFragments = peerId => { + peerReceiveLimits.delete(peerId); + for (const [fid, entry] of Object.entries(receivingFragments)) { + if (entry.peerId === peerId) { + discardFragment(fid); + delete receivingFragments[fid]; + } + } + if (!Object.keys(receivingFragments).length) receivingFragmentStats = null; + }; + const resetReceivingFragments = async () => { + clearReceiving(); + receivingFragmentStats = null; await resetP2PMsg(); }; @@ -754,6 +982,7 @@ module.exports = async (inputData = null) => { const entry = receivingFragments[fid]; if (entry && typeof entry.lastReceivedAt === 'number' && (now - entry.lastReceivedAt) > FRAGMENT_TTL_MS) { + discardFragment(fid); delete receivingFragments[fid]; pruned += 1; } @@ -791,6 +1020,7 @@ module.exports = async (inputData = null) => { } dataChannelHeartbeatTimers[remotePeerId] = setInterval(() => { try { + pruneStaleReceivingFragments(); if (run.isCurrent() && channel.readyState === 'open') { channel.send(P2P_DC_KEEPALIVE_JSON); } @@ -802,6 +1032,9 @@ module.exports = async (inputData = null) => { async function cleanupPeerConnections() { p2pShuttingDown = true; + peerReceiveLimits.clear(); + clipboardJobs.cancel(); + cancelIncoming(); for (const id of Object.keys(peerConnectionDeadlines)) { cancelPeerConnectionDeadline(id); } @@ -986,7 +1219,8 @@ module.exports = async (inputData = null) => { initializeWebSocketSignalingClient(); // send clipboard content P2P - sendClipBoardP2P = async (clipContent, type_ = 'text') => { + sendClipBoardP2P = async (clipContent, type_ = 'text', generation) => { + const checkCurrent = () => checkClipboardJob(generation); try { if (!isShareTransportReady()) return false; await clearFiles(); @@ -997,98 +1231,129 @@ module.exports = async (inputData = null) => { return; } - if (await validateClipboardSize(clipContent, type_, 'Outbound')) { - // base64 encode - if (type_ === 'image') { - clipContent = await NativeBridgeModule.getFileAsBase64( - clipContent, - ); - } else if (type_ === 'files') { - clipContent = await encodeFiles(clipContent, NativeBridgeModule); + let contentSizeBytes = await measureClipboardContent(clipContent, type_, 'Outbound'); + const eligiblePeers = () => Object.entries(dataChannels).filter(([peer, channel]) => + channel.readyState === 'open' && allowsContentSize(peerReceiveLimits.get(peer), contentSizeBytes)) + .map(([, channel]) => channel); + if (await validateClipboardSize(clipContent, type_, 'Outbound', contentSizeBytes)) { + checkCurrent(); + if (!eligiblePeers().length) { + p2pMsg = '⚠️ Clipboard not sent: content exceeds connected peers’ receive limits.'; + await p2pStatusMessageChanged(); + return true; + } + checkCurrent(); + const recipients = eligiblePeers(); + const recipientIds = new Map(Object.entries(dataChannels).map(([peer, channel]) => [channel, peer])); + const binaryPeers = new Set(recipients.filter(channel => channel._ccBinary === true)); + // Load original bytes once for this recipient snapshot. + if (type_ === 'image' || type_ === 'files') { + clipContent = await loadClipboardAttachment(clipContent, type_, generation, size => { contentSizeBytes = size; }); } - // clipboad content hash - const hcb = await hashCB(clipContent); + checkCurrent(); + if (type_ === 'image') contentSizeBytes = clipContent.length; + if (!(await validateClipboardSize(clipContent, type_, 'Outbound', contentSizeBytes))) return true; + checkCurrent(); + if (!eligiblePeers().length) { + p2pMsg = '⚠️ Clipboard not sent: content exceeds connected peers’ receive limits.'; + await p2pStatusMessageChanged(); + return true; + } + // clipboard content hash + const hcb = await hashCB(clipContent, type_); + checkCurrent(); if (await newCB(hcb)) { if (block_image_once) { block_image_once = false; } else { + checkCurrent(); await resetSendingFragmentId(); + checkCurrent(); await resetReceivingFragments(); + checkCurrent(); - const rawPayloadSizeInBytes = - textEncoder.encode(clipContent).length; - - if (cipher_enabled === 'true') { - //ecrypt - clipContent = await encrypt(clipContent); - } - - // fragment payload - const fragments = await fragmentString( - clipContent, - FRAGMENT_SIZE, - ); - - const metadata = { - id: await generateUuid(), - isFragmented: fragments.length > 1, - index: 0, - totalFragments: fragments.length, - combinedRawPayloadSizeInBytes: rawPayloadSizeInBytes, + let legacy = null; + const getLegacy = () => { + if (legacy === null) legacy = binary.legacyContent(clipContent, type_); + return legacy; }; - - const recipients = Object.values(dataChannels).filter(channel => channel.readyState === 'open'); if (!recipients.length) return false; - let loopBroken = false; - sendingFragmentId = metadata.id; - for (let i = 0; i < fragments.length; i++) { - if (!(await isClipboardSyncEnabled('send_clipboard'))) { - await resetSendingFragmentId(); - return; - } - if (sendingFragmentId != metadata.id) { - loopBroken = true; - return false; - } - - const fragment = fragments[i]; - - const messageJson = JSON.stringify({ - payload: fragment, - type: type_, - metadata: metadata, - }); - metadata.index += 1; - - // A peer joining midway cannot receive a partial batch. - let sent = false; - for (const channel of recipients) { - if (run.isCurrent() && !transportStopped && channel.readyState === 'open') { - await channel.send(messageJson); - sent = true; + const encrypted = cipher_enabled === 'true'; + const groups = []; + for (let useBinary of [false, true]) { + const channels = recipients.filter(channel => binaryPeers.has(channel) === useBinary); + if (!channels.length) continue; + checkCurrent(); + let parts; + let combinedRawPayloadSizeInBytes = 0; + if (useBinary) { + try { + const raw = await binary.encodeContent(clipContent, type_, encrypted ? encryptBinary : null, + size => { combinedRawPayloadSizeInBytes = size; }, checkCurrent); + parts = binary.fragments(raw, FRAGMENT_SIZE); + } catch (error) { + if (error.message !== 'Binary header too large') throw error; + parts = []; } + if (!parts.length || parts.length > 65536) { + useBinary = false; + combinedRawPayloadSizeInBytes = Buffer.byteLength(getLegacy(), 'utf8'); + parts = await fragmentString(encrypted ? await encrypt(getLegacy()) : getLegacy(), FRAGMENT_SIZE, checkCurrent); + } + } else { + combinedRawPayloadSizeInBytes = Buffer.byteLength(getLegacy(), 'utf8'); + const prepared = encrypted ? await encrypt(getLegacy()) : getLegacy(); + parts = await fragmentString(prepared, FRAGMENT_SIZE, checkCurrent); } - if (!sent) { - await resetSendingFragmentId(); - return false; - } - - // Update stats - if (metadata.isFragmented) { - sendingFragmentStats = `${metadata.index}/${metadata.totalFragments}`; - await p2pStatusMessageChanged(); - } + groups.push({useBinary, channels, fragments: parts, combinedRawPayloadSizeInBytes}); } - if (!loopBroken) { + checkCurrent(); + clipContent = null; + const metadata = { + id: await generateUuid(), contentSizeBytes, + }; + checkCurrent(); + sendingFragmentId = metadata.id; + try { + const results = await Promise.all(groups.map(group => sendSharedClipboardFragments( + group.fragments, group.channels, + {...metadata, combinedRawPayloadSizeInBytes: group.combinedRawPayloadSizeInBytes, + isFragmented: group.fragments.length > 1, index: 0, totalFragments: group.fragments.length}, + type_, textEncoder, + () => { + checkCurrent(); + if (sendingFragmentId !== metadata.id) throw new ClipboardSuperseded(); + }, + async () => { + if (!(await isClipboardSyncEnabled('send_clipboard'))) throw new ClipboardSuperseded(); + }, + (index, total) => { + if (generation === clipboardJobs.generation && sendingFragmentId === metadata.id) { + sendingFragmentStats = `${index}/${total}`; + isP2PStatusMsgChanged = true; + } + }, + channel => dataChannels[recipientIds.get(channel)] === channel + && allowsContentSize(peerReceiveLimits.get(recipientIds.get(channel)), contentSizeBytes), + group.useBinary ? (part, meta) => binary.encodeFrame(part, type_, meta, encrypted) : null, + ))); + const completed = results.reduce((sum, count) => sum + count, 0); + checkCurrent(); + if (!completed) return false; previous_clipboard_content_hash = hcb; - await resetSendingFragmentId(); + } finally { + if (sendingFragmentId === metadata.id) await resetSendingFragmentId(); } } } } } catch (e) { + checkCurrent(); + if (isClipboardSuperseded(e)) throw e; block_image_once = false; + await resetSendingFragmentId(); + checkCurrent(); p2pMsg = '❌ P2P Outbound Error: ' + JSON.stringify(e, null, 2); await p2pStatusMessageChanged(); throw e; @@ -1098,6 +1363,9 @@ module.exports = async (inputData = null) => { // stop events and connection P2P stopServicesP2P = async () => { transportStopped = true; + clipboardJobs.cancel(); + cancelIncoming(); + files_in_memory = null; // This service must stay stopped even if a newer service sets the // shared running flag back to true before an old retry completes. signalingStopped = true; @@ -1160,10 +1428,15 @@ module.exports = async (inputData = null) => { }; // receive clipboard content P2P - const onDataChannelMessage = async messageJson => { + const onDataChannelMessage = async (messageJson, peerId) => { try { - const message = JSON.parse(messageJson); + const message = typeof messageJson === 'string' ? JSON.parse(messageJson) : binary.decodeFrame(messageJson); + const isBinary = message._cc_binary === true; if (message && message._cc_keepalive === true) { + const channel = dataChannels[peerId]; + if (channel) channel._ccBinary = message.capabilities?.version === 1 + && Array.isArray(message.capabilities.transferFormats) && message.capabilities.transferFormats.includes('ccb1'); + readReceiveCapability(message, peerReceiveLimits, peerId); return; } @@ -1171,26 +1444,37 @@ module.exports = async (inputData = null) => { await resetReceivingFragments(); return; } - await clearFiles(true); - await resetSendingFragmentId(); - let cb = String(message.payload); + let cb = isBinary ? message.payload : String(message.payload); const type_ = message.type ?? 'text'; const metadata = message.metadata; - + if (!acceptsClipboardFragment(metadata, receivingFragments, discardedFragments)) return; // Check if the payload exceeds the maximum size: first layer protection - if ( - metadata != null && - max_clipboard_size_local_limit_bytes >= 0 && - metadata.combinedRawPayloadSizeInBytes > - max_clipboard_size_local_limit_bytes - ) { - await resetReceivingFragments(); - p2pMsg = `⚠️ Payload size limit exceeded: ${metadata['combinedRawPayloadSizeInBytes']} bytes exceeds ${max_clipboard_size_local_limit_bytes} bytes`; + // Legacy payload sizes include overhead. When original size is + // absent, defer to the decoded-content validation below. + const announcedSize = metadataContentSize(metadata); + if (!allowsContentSize(localReceiveLimit, announcedSize)) { + if (metadata?.id) { + delete receivingFragments[metadata.id]; + discardFragment(metadata.id); + } + if (!Object.keys(receivingFragments).length) receivingFragmentStats = null; + p2pMsg = `⚠️ Content size limit exceeded: ${announcedSize} bytes exceeds ${max_clipboard_size_local_limit_bytes} bytes`; await p2pStatusMessageChanged(); return; } + let generation = metadata && receivingFragments[metadata.id]?.generation; + if (metadata == null || (metadata.index === 0 && generation == null)) { + generation = clipboardJobs.cancel(); + await resetSendingFragmentId(); + checkClipboardJob(generation); + await clearFiles(true); + } + checkClipboardJob(generation); + await clearShareFailure(generation); + checkClipboardJob(generation); + // Fragmented message handling if (metadata != null && metadata.isFragmented) { pruneStaleReceivingFragments(); @@ -1198,9 +1482,21 @@ module.exports = async (inputData = null) => { metadata.totalFragments }`; await p2pStatusMessageChanged(); + checkClipboardJob(generation); if (metadata.id in receivingFragments) { const entry = receivingFragments[metadata.id]; + if (entry.peerId !== peerId || entry.fragments.length !== metadata.totalFragments || entry.type !== type_ || entry.binary !== isBinary || entry.encrypted !== message.encrypted) { + await resetReceivingFragments(); + return; + } + checkClipboardJob(entry.generation); + if (isBinary) { + if (metadata.index !== entry.nextIndex || metadata.contentSizeBytes !== entry.size + || entry.bytes + cb.length > entry.size + 65536 + 64) throw new Error('Invalid binary fragment sequence or size'); + entry.nextIndex++; + entry.bytes += cb.length; + } entry.fragments[metadata.index] = cb; entry.lastReceivedAt = Date.now(); @@ -1209,7 +1505,8 @@ module.exports = async (inputData = null) => { // Check if all fragments are present (none is empty) if (entry.fragments.every(frag => frag !== '')) { // Join them all together into one payload - cb = entry.fragments.join(''); + cb = isBinary ? binary.concat(entry.fragments) : entry.fragments.join(''); + discardFragment(metadata.id); delete receivingFragments[metadata.id]; } else { // Missing fragment(s): error out @@ -1225,8 +1522,11 @@ module.exports = async (inputData = null) => { } } else { await resetReceivingFragments(); + checkClipboardJob(generation); receivingFragments[metadata.id] = { lastReceivedAt: Date.now(), + peerId, generation, type: type_, binary: isBinary, encrypted: message.encrypted, + nextIndex: 1, bytes: cb.length, size: metadata.contentSizeBytes, fragments: Array(metadata.totalFragments).fill(''), }; receivingFragments[metadata.id].fragments[metadata.index] = cb; @@ -1234,10 +1534,14 @@ module.exports = async (inputData = null) => { } } + checkClipboardJob(generation); await clearFiles(); + checkClipboardJob(generation); // decrypt - if (cipher_enabled === 'true') { + if (isBinary) { + cb = await decodeBinary({...message, payload: cb}); + } else if (cipher_enabled === 'true') { try { cb = await decrypt(JSON.parse(cb)); } catch (error) { @@ -1247,8 +1551,10 @@ module.exports = async (inputData = null) => { } } + checkClipboardJob(generation); // hash clipboard content - const hcb = await hashCB(cb); + const hcb = await hashCB(cb, type_); + checkClipboardJob(generation); if (await newCB(hcb)) { previous_clipboard_content_hash = hcb; @@ -1260,16 +1566,18 @@ module.exports = async (inputData = null) => { return; } await NativeBridgeModule.discardPendingShare(); + checkClipboardJob(generation); // set clipboard content if (type_ === 'text') { await NativeBridgeModule.copyTextToClipboard(cb); } else if (type_ === 'image') { + block_image_once = true; await NativeBridgeModule.copyBase64ImageToClipboardUsingCache( - cb, + binary.legacyContent(cb, type_), ); - block_image_once = true; } else if (type_ === 'files') { await showFilesDownloadNotification(cb); + checkClipboardJob(generation); files_in_memory = cb; await setDataInAsyncStorage( @@ -1279,7 +1587,10 @@ module.exports = async (inputData = null) => { } } } + await clearShareFailure(generation); } catch (e) { + if (isClipboardSuperseded(e)) return; + await resetReceivingFragments(); p2pMsg = '❌ P2P Inbound Error: ' + e; await p2pStatusMessageChanged(); } @@ -1326,6 +1637,7 @@ module.exports = async (inputData = null) => { ); // 2) For each stale peer, close data channel and peer connection for (const oldPid of stalePeerIds) { + dropPeerFragments(oldPid); cancelPeerConnectionDeadline(oldPid); delete peerOpChains[oldPid]; delete peerRetryAttempts[oldPid]; @@ -1358,6 +1670,7 @@ module.exports = async (inputData = null) => { }; const disposePeerConnection = async peerId => { + dropPeerFragments(peerId); cancelPeerConnectionDeadline(peerId); if (dataChannelHeartbeatTimers[peerId]) { clearInterval(dataChannelHeartbeatTimers[peerId]); @@ -1621,20 +1934,26 @@ module.exports = async (inputData = null) => { * Set up DataChannel for remotePeerId. */ const setupDataChannel = async (remotePeerId, channel) => { + channel.binaryType = 'arraybuffer'; channel.onopen = track(async () => { if (dataChannels[remotePeerId] !== channel) return; cancelPeerConnectionDeadline(remotePeerId); delete peerRetryAttempts[remotePeerId]; + channel.send(P2P_DC_KEEPALIVE_JSON); startDataChannelHeartbeat(remotePeerId, channel); await syncLiveConnectionsCount(); }); - channel.onmessage = track(async e => { - await onDataChannelMessage(e.data); - }); + const receiveMessage = serializeClipboardMessages(async data => { + if (run.isCurrent() && !transportStopped && dataChannels[remotePeerId] === channel) { + await onDataChannelMessage(data, remotePeerId); + } + }, () => localClipboardEpoch); + channel.onmessage = track(e => receiveMessage(e.data)); channel.onclose = track(async () => { if (dataChannels[remotePeerId] !== channel) return; + dropPeerFragments(remotePeerId); const pc = peerConnections[remotePeerId]; if (dataChannelHeartbeatTimers[remotePeerId]) { clearInterval(dataChannelHeartbeatTimers[remotePeerId]); @@ -1658,16 +1977,36 @@ module.exports = async (inputData = null) => { // send clipboard content const sendClipBoard = async (clipContent, type_ = 'text') => { if (!run.isCurrent() || transportStopped) return false; - if (type_ !== 'image') block_image_once = false; - if (!(await isClipboardSyncEnabled('send_clipboard'))) { - block_image_once = false; - return; - } - const sent = server_mode === 'P2S' - ? await sendClipBoardP2S(clipContent, type_) - : await sendClipBoardP2P(clipContent, type_); - if (sent === false) { - throw new Error('Clipboard not sent: connection unavailable, busy, or interrupted. Copy/share it again when connected.'); + // Native observation metadata identifies our own writes. A real + // local copy must not be suppressed merely because its text or URI + // matches a previous received item. + block_image_once = false; + localClipboardEpoch++; + const generation = clipboardJobs.cancel(); + cancelIncoming(); + let sent; + try { + await clearFiles(); + checkClipboardJob(generation); + await clearShareFailure(generation); + checkClipboardJob(generation); + if (!(await isClipboardSyncEnabled('send_clipboard'))) return; + checkClipboardJob(generation); + sent = server_mode === 'P2S' + ? await sendClipBoardP2S(clipContent, type_, generation) + : await sendClipBoardP2P(clipContent, type_, generation); + checkClipboardJob(generation); + if (sent === false) { + throw new Error('Clipboard not sent: connection unavailable, busy, or interrupted. Copy/share it again when connected.'); + } + await clearShareFailure(generation); + } catch (error) { + // A newer local copy or inbound item can arrive while an awaited + // send is returning false or failing. It is cancellation, not a + // connection error belonging to the current clipboard item. + if (generation !== clipboardJobs.generation) return true; + if (isClipboardSuperseded(error)) return true; + throw error; } return sent; }; @@ -1682,8 +2021,9 @@ module.exports = async (inputData = null) => { isShareTransportReady, async (content, type) => { block_image_once = false; + const expectedGeneration = clipboardJobs.generation + 1; const result = await sendClipBoard(content, type); - if (run.isCurrent() && type === 'text') { + if (run.isCurrent() && type === 'text' && clipboardJobs.generation === expectedGeneration) { try { await NativeBridgeModule.copyTextToClipboard(content); } catch (error) { @@ -1693,6 +2033,7 @@ module.exports = async (inputData = null) => { return result; }, error => setDataInAsyncStorage('wsStatusMessage', '❌ Share failed: ' + error), + revision => clipboardRevisions.baseline(revision), )) { // Each entry is consumed once; this only handles a newly arrived share. } @@ -1796,7 +2137,7 @@ module.exports = async (inputData = null) => { }; try { // Snapshot the batch; incoming clipboard updates can replace files_in_memory. - const files = JSON.parse(files_in_memory || '{}'); + const files = typeof files_in_memory === 'string' ? JSON.parse(files_in_memory) : (files_in_memory || {}); const entries = Object.entries(files); total = entries.length; await setDataInAsyncStorage('fileDownloadInProgress', 'true'); @@ -1809,7 +2150,7 @@ module.exports = async (inputData = null) => { for (const [name, content] of entries) { await NativeBridgeModule.saveBase64Files( dirPath, - JSON.stringify({[name]: content}), + JSON.stringify({[name]: typeof content === 'string' ? content : binary.toBase64(content)}), ); saved += 1; await reportDownload(`Saving ${saved} of ${total} ${total === 1 ? 'file' : 'files'}…`, true); diff --git a/ClipCascade_Mobile/src/StompSocket.js b/ClipCascade_Mobile/src/StompSocket.js new file mode 100644 index 000000000..203e1cae5 --- /dev/null +++ b/ClipCascade_Mobile/src/StompSocket.js @@ -0,0 +1,83 @@ +import {fromBase64, toBase64, bytes} from './BinaryProtocol'; +let nextSocketId = 0; + +// STOMP still sees one continuous byte stream. Native code splits its frames +// into small binary WebSocket messages and waits for the OkHttp queue to drain. +export class StompSocket { + constructor(url, bridge, events) { + this.url = url; + this.readyState = 0; + this.binaryType = 'arraybuffer'; + this._bridge = bridge; + this._id = ++nextSocketId; + this._pending = new Set(); + this._failure = null; + this._sendChain = Promise.resolve(); + this._listener = events.addListener('CLIPCASCADE_STOMP_SOCKET', event => { + if (event.id !== this._id) return; + if (event.type === 'open') { + this.readyState = 1; + this.onopen?.({type: 'open', target: this}); + } else if (event.type === 'binary') { + this.onmessage?.({data: fromBase64(event.data).buffer, target: this}); + } else if (event.type === 'message') { + this.onmessage?.({data: JSON.parse(event.data), target: this}); + } else if (event.type === 'error') { + this.onerror?.({message: event.data, target: this}); + } else if (event.type === 'close') { + this.readyState = 3; + this._listener.remove(); + this.onclose?.({code: event.code, reason: event.data, target: this}); + } + }); + bridge.connectStompSocket(this._id, url, ['v12.stomp', 'v11.stomp', 'v10.stomp']); + } + + send(frame) { + if (this.readyState !== 1) throw new Error('STOMP connection is not open'); + const binary = typeof frame !== 'string'; + // A literal NUL can be truncated by the React Native string bridge. + // Send the STOMP terminator separately and restore it natively. + const terminated = !binary && frame.endsWith('\0'); + // Serialize complete frames so heartbeats cannot interleave with a binary + // frame's bounded bridge chunks. Native writes retain per-socket backpressure. + const pending = this._sendChain.then(async () => { + if (this._failure) throw this._failure; + if (binary) { + const data = bytes(frame); + for (let offset = 0; offset < data.length; offset += 32768) { + const end = Math.min(offset + 32768, data.length); + await this._bridge.sendStompBinaryChunk(this._id, toBase64(data.subarray(offset, end)), end === data.length); + } + } else { + await this._bridge.sendStompFrame(this._id, terminated ? frame.slice(0, -1) : frame, terminated); + } + }); + this._sendChain = pending.catch(error => { this._failure = error; }); + this._pending.add(pending); + pending.then(() => this._pending.delete(pending), error => { + this._failure = error; + this._pending.delete(pending); + this.onerror?.({message: error.message || String(error), target: this}); + }); + } + + async flush() { + if (this._failure) throw this._failure; + await Promise.all([...this._pending]); + if (this._failure) throw this._failure; + if (this.readyState !== 1) throw new Error('STOMP connection closed during send'); + } + + close(code = 1000, reason = '') { + if (this.readyState >= 2) return; + this.readyState = 2; + this._bridge.closeStompSocket(this._id, code, reason, false); + } + + terminate() { + if (this.readyState === 3) return; + this.readyState = 2; + this._bridge.closeStompSocket(this._id, 1000, 'Service stopped', true); + } +} diff --git a/ClipCascade_Mobile/src/TransferLimits.js b/ClipCascade_Mobile/src/TransferLimits.js new file mode 100644 index 000000000..47c1fccd6 --- /dev/null +++ b/ClipCascade_Mobile/src/TransferLimits.js @@ -0,0 +1,39 @@ +export const validContentSize = value => Number.isSafeInteger(value) && value >= 0; + +export function normalizeReceiveLimit(value) { + if (value == null || value === '' || value === 'unlimited') return null; + const number = typeof value === 'number' ? value : (/^[0-9]+$/.test(value) ? Number(value) : null); + return validContentSize(number) ? number : null; +} + +export const receiveLimitHeader = limit => limit == null ? 'unlimited' : String(limit); +export const allowsContentSize = (limit, size) => !validContentSize(limit) || !validContentSize(size) || size <= limit; +// Original bytes only. Keep the legacy P2P payload-size field separate so +// older receivers retain their existing behavior. +export const metadataContentSize = metadata => validContentSize(metadata?.contentSizeBytes) ? metadata.contentSizeBytes : null; + +export function receiveCapabilityMessage(limit) { + return JSON.stringify({_cc_keepalive: true, capabilities: {version: 1, transferFormats: ['ccb1'], receiveLimitBytes: limit}}); +} + +export function readReceiveCapability(message, limits, peer) { + const capability = message?.capabilities; + if (capability?.version !== 1 || !Object.prototype.hasOwnProperty.call(capability, 'receiveLimitBytes')) return; + if (capability.receiveLimitBytes === null || validContentSize(capability.receiveLimitBytes)) { + limits.set(peer, capability.receiveLimitBytes); + } +} + +export function base64ContentSize(encoded) { + let length = encoded.length; + const whitespace = /\s+/g; + let match; + while ((match = whitespace.exec(encoded)) !== null) length -= match[0].length; + let padding = 0; + for (let end = encoded.length - 1; end >= 0; end--) { + const char = encoded[end]; + if (char === '=') padding++; + else if (!/\s/.test(char)) break; + } + return Math.floor(length / 4) * 3 - padding; +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardFloatingActivity.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardFloatingActivity.kt index f99df63ae..b7bdd33ac 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardFloatingActivity.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardFloatingActivity.kt @@ -68,13 +68,11 @@ class ClipboardFloatingActivity : AppCompatActivity() { } private fun getClipboardContent() { - clipboardManager.primaryClip?.let { clip -> - ClipboardContent.fromClipboard(clip, contentResolver)?.let { content -> - PendingShares.clear() - reactContext - ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit("onClipboardChange", content.toMap()) - } + ClipboardObservations.observe(clipboardManager.primaryClip, contentResolver)?.let { update -> + if (!update.isEcho) PendingShares.clear() + reactContext + ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit("onClipboardChange", update.toMap()) } } @@ -120,8 +118,11 @@ class ClipboardFloatingActivity : AppCompatActivity() { companion object { fun getIntent(context: Context): Intent { return Intent(context.applicationContext, ClipboardFloatingActivity::class.java).apply { + // The share activity owns temporary attachment URI grants. + // Never clear or reuse its task while background reads run. + // This short-lived helper finishes its own separate task. flags = Intent.FLAG_ACTIVITY_NEW_TASK or - Intent.FLAG_ACTIVITY_CLEAR_TASK or + Intent.FLAG_ACTIVITY_MULTIPLE_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS } } diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardListenerModule.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardListenerModule.kt index b497e588d..16a157cf4 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardListenerModule.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardListenerModule.kt @@ -50,16 +50,21 @@ class ClipboardListenerModule(reactContext: ReactApplicationContext) : ReactCont return } + // Existing clipboard content is a baseline, not a fresh copy. On newer + // Android versions background access may be denied; the foreground + // share activity also records a baseline when a share is accepted. + try { + ClipboardObservations.baseline(clipboardManager.primaryClip, reactApplicationContext.contentResolver) + } catch (_: SecurityException) {} + // 1) Clipboard listener listener = ClipboardManager.OnPrimaryClipChangedListener { try { - clipboardManager.primaryClip?.let { clip -> - ClipboardContent.fromClipboard(clip, reactApplicationContext.contentResolver) - ?.let { - PendingShares.clear() - sendEventToJS(it.toMap()) - } - } + ClipboardObservations.observe(clipboardManager.primaryClip, reactApplicationContext.contentResolver) + ?.let { + if (!it.isEcho) PendingShares.clear() + sendEventToJS(it.toMap()) + } } catch (e: Exception) { android.util.Log.e("ClipCascade", "Failed to read clipboard", e) } diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardObservations.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardObservations.kt new file mode 100644 index 000000000..272baba04 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ClipboardObservations.kt @@ -0,0 +1,70 @@ +package com.clipcascade + +import android.content.ClipData +import android.content.ContentResolver +import android.os.Build +import com.facebook.react.bridge.WritableMap + +/** One observation history shared by the listener and the floating reader. */ +object ClipboardObservations { + const val OWN_WRITE_KEY = "com.clipcascade.clipboard.write" + private data class Snapshot(val content: ClipboardContent?, val timestamp: Long?) + private var previous: Snapshot? = null + private var revision = 0L + private var shareAcceptedAt = 0L + private val ownWrites = LinkedHashSet() + + data class Update(val content: ClipboardContent, val revision: String, val isEcho: Boolean) { + fun toMap(): WritableMap = content.toMap().apply { + putString("clipboardRevision", revision) + putBoolean("clipboardEcho", isEcho) + } + } + + @Synchronized + fun expectOwnWrite(id: String) { + ownWrites.add(id) + if (ownWrites.size > 128) ownWrites.remove(ownWrites.first()) + } + + @Synchronized + fun forgetOwnWrite(id: String) { ownWrites.remove(id) } + + @Synchronized + fun currentRevision(): String = revision.toString() + + @Synchronized + fun baseline(clip: ClipData?, resolver: ContentResolver): String { + // A denied background read can return null. Do not erase a known + // clipboard snapshot just because the app temporarily lacks focus. + if (clip != null) observe(clip, resolver) + return revision.toString() + } + + @Synchronized + fun beginShare(clip: ClipData?, resolver: ContentResolver): String { + shareAcceptedAt = System.currentTimeMillis() + return baseline(clip, resolver) + } + + @Synchronized + fun observe(clip: ClipData?, resolver: ContentResolver): Update? { + // Android can return null when background access is denied. A null + // read must not turn a later reread into a new observation. + if (clip == null) return null + val content = ClipboardContent.fromClipboard(clip, resolver) + // The timestamp distinguishes a fresh copy of identical text from a + // second read of the same clipboard item on Android 8 and newer. + val timestamp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) clip.description.timestamp else null + val snapshot = Snapshot(content, timestamp) + if (snapshot == previous) return null + previous = snapshot + revision++ + // Sharing may reach onCreate before Android grants clipboard access. + // A later focused reread of an older item is still only a baseline. + if (timestamp != null && timestamp < shareAcceptedAt) return null + val ownWrite = clip.description.extras?.getString(OWN_WRITE_KEY) + val isEcho = ownWrite != null && ownWrites.remove(ownWrite) + return content?.let { Update(it, revision.toString(), isEcho) } + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt index 370fb2bb2..76637d66e 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt @@ -55,11 +55,8 @@ class MainActivity : ReactActivity() { try{ val bridgeData = AsyncStorageBridge(applicationContext) val enablePeriodicChecks = bridgeData.getValue("enable_periodic_checks")?.toBoolean() ?: true - if(enablePeriodicChecks) { + if(enablePeriodicChecks) { scheduleJob(applicationContext) - if(ScheduleService.hasNotificationPermission(applicationContext) == true) { - ScheduleService.removeNotificationIfPresent(applicationContext) - } } else { WorkManager.getInstance(applicationContext).cancelAllWorkByTag(WORK_NAME) } @@ -68,6 +65,16 @@ class MainActivity : ReactActivity() { } } + override fun onStart() { + super.onStart() + ScheduleService.onActivityStarted(applicationContext) + } + + override fun onStop() { + ScheduleService.onActivityStopped() + super.onStop() + } + override fun onPause() { super.onPause() val rm: ReactInstanceManager = reactNativeHost.reactInstanceManager @@ -90,7 +97,15 @@ class MainActivity : ReactActivity() { clearShareIntent(intent) } ClipboardContent.fromIntent(intent, contentResolver)?.let { content -> - PendingShares.add(content) + // A delayed clipboard reread must not supersede this explicit + // share merely because the clipboard still contains older text. + val baseline = try { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + ClipboardObservations.beginShare(clipboard.primaryClip, contentResolver) + } catch (_: SecurityException) { + ClipboardObservations.beginShare(null, contentResolver) + } + PendingShares.add(content, baseline) // Buffer first, then consume before notifying JavaScript. Keep URI // grant flags so the buffered attachments remain readable. clearShareIntent(intent) diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt index f578dbe4c..2286cca53 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt @@ -35,16 +35,111 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.FileOutputStream import java.io.IOException +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicLong +import android.util.Base64OutputStream class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { + companion object { + // Shared across module reloads: startup cleanup must also protect files + // owned by another live module in this process, including finished streams. + private val cipherFileLock = Any() + private val activeCipherFiles = mutableSetOf() + + private fun createCipherFile(cacheDir: File): File = synchronized(cipherFileLock) { + File.createTempFile("clipboard-cipher-", ".tmp", cacheDir).also { activeCipherFiles.add(it) } + } + + private fun deleteCipherFile(file: File) = synchronized(cipherFileLock) { + try { + if (file.exists() && !file.delete()) { + android.util.Log.w("ClipboardPreparation", "Could not delete cipher temporary file") + } + } finally { + activeCipherFiles.remove(file) + } + } + + private fun cleanAbandonedCipherFiles(cacheDir: File) = synchronized(cipherFileLock) { + cacheDir.listFiles()?.forEach { file -> + if (file.isFile && file.name.startsWith("clipboard-cipher-") + && file.name.endsWith(".tmp") && file !in activeCipherFiles) { + deleteCipherFile(file) + } + } + } + } + private val asyncBridge = AsyncStorageBridge(reactContext) + private val stompSockets = NativeStompSocket(reactContext) + private val clipboardReadGeneration = AtomicLong() + @Volatile private var ownClipboardImageUri: String? = null + // File/image reads must not occupy React Native's shared module queue. + // One worker also avoids concurrently loading multiple large attachments. + private val clipboardReads = Executors.newSingleThreadExecutor { task -> + Thread(task, "ClipboardPreparation").apply { isDaemon = true } + } + + init { + // Process death skips normal cleanup. Reclaim its files before any new + // cipher work, off the UI/module queue; preserve unrelated cache content. + clipboardReads.execute { + runCatching { cleanAbandonedCipherFiles(reactContext.cacheDir) } + .onFailure { android.util.Log.w("ClipboardPreparation", "Cipher cache cleanup failed", it) } + } + } override fun getName(): String { return "NativeBridgeModule" } + @ReactMethod + fun connectStompSocket(id: Double, url: String, protocols: ReadableArray) { + stompSockets.connect(id.toInt(), url, protocols) + } + + @ReactMethod + fun sendStompFrame(id: Double, frame: String, terminated: Boolean, promise: Promise) { + stompSockets.send(id.toInt(), frame, terminated, promise) + } + + @ReactMethod + fun sendStompBinaryFrame(id: Double, frame: String, promise: Promise) { + stompSockets.send(id.toInt(), frame, false, promise, true) + } + + @ReactMethod + fun sendStompBinaryChunk(id: Double, chunk: String, last: Boolean, promise: Promise) { + if (chunk.length > 43692) { promise.reject("STOMP_CHUNK_SIZE", "Bridge chunk too large"); return } + stompSockets.send(id.toInt(), chunk, false, promise, true, last) + } + + @ReactMethod + fun closeStompSocket(id: Double, code: Double, reason: String, force: Boolean) { + stompSockets.close(id.toInt(), code.toInt(), reason, force) + } + + @ReactMethod + fun readProtectedValue(name: String, promise: Promise) { + try { + promise.resolve(ProtectedStorage.read(asyncBridge, name)) + } catch (error: Exception) { + promise.reject("PROTECTED_STORAGE_READ", "Could not read protected ClipCascade data", error) + } + } + + @ReactMethod + fun writeProtectedValue(name: String, value: String, promise: Promise) { + try { + ProtectedStorage.write(asyncBridge, name, value) + promise.resolve(null) + } catch (error: Exception) { + promise.reject("PROTECTED_STORAGE_WRITE", "Could not save protected ClipCascade data", error) + } + } + @ReactMethod(isBlockingSynchronousMethod = true) fun getServiceRunIdSync(): String? = ServiceRunRegistry.currentId() @@ -70,7 +165,11 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa } @ReactMethod(isBlockingSynchronousMethod = true) - fun activateServiceRunSync(id: String): Boolean = ServiceRunRegistry.activate(id) + fun activateServiceRunSync(id: String): Boolean { + if (!ServiceRunRegistry.activate(id)) return false + ScheduleService.removeNotificationIfPresent(reactApplicationContext) + return true + } @ReactMethod(isBlockingSynchronousMethod = true) fun isServiceRunCurrentSync(id: String): Boolean = ServiceRunRegistry.isCurrent(id) @@ -261,18 +360,202 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa } } + // The classic RN bridge has no byte-buffer value. Numeric arrays require + // per-byte JNI calls, so use bounded Base64 bridge chunks, never a whole + // attachment string. Streams remain on the preparation executor. + private val attachmentStreams = mutableMapOf>() + private var nextAttachmentStream = 0L + + private data class ClipboardCipherJob( + val generation: Long, val cipher: javax.crypto.Cipher, val file: File, + val output: FileOutputStream, val decrypt: Boolean, val tag: ByteArray + ) + private val clipboardCiphers = mutableMapOf() + + private fun bridgeBytes(values: ReadableArray): ByteArray { + require(values.size() <= 32 * 1024) { "Bridge chunk too large" } + return ByteArray(values.size()) { index -> + val value = values.getInt(index) + require(value in 0..255) { "Invalid byte" } + value.toByte() + } + } + + // AES work and temporary output stay off RN/UI threads. Decrypted output is + // exposed only after doFinal authenticates the complete GCM message. + @ReactMethod + fun beginClipboardCipher(key: String, decrypt: Boolean, nonce: ReadableArray, tag: ReadableArray, promise: Promise) { + val generation = clipboardReadGeneration.get() + val iv = bridgeBytes(nonce) + val authTag = bridgeBytes(tag) + clipboardReads.execute { + try { + if (generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + val cipher = javax.crypto.Cipher.getInstance("AES/GCM/NoPadding") + val secret = javax.crypto.spec.SecretKeySpec(Base64.decode(key, Base64.DEFAULT), "AES") + if (decrypt) { + require(iv.size in 8..32 && authTag.size == 16) + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secret, javax.crypto.spec.GCMParameterSpec(128, iv)) + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, secret) + } + val file = createCipherFile(reactApplicationContext.cacheDir) + val id = (++nextAttachmentStream).toString() + val output = try { FileOutputStream(file) } catch (error: Exception) { deleteCipherFile(file); throw error } + clipboardCiphers[id] = ClipboardCipherJob(generation, cipher, file, output, decrypt, authTag) + promise.resolve(id) + } catch (error: Exception) { promise.reject(if (error is java.io.InterruptedIOException) "CLIPBOARD_SUPERSEDED" else "CIPHER_ERROR", "Cannot begin clipboard cipher", error) } + } + } + + @ReactMethod + fun appendClipboardCipher(id: String, encoded: String, promise: Promise) { + clipboardReads.execute { + try { + require(encoded.length <= 43692) { "Bridge chunk too large" } + val block = Base64.decode(encoded, Base64.NO_WRAP) + require(block.size <= 32 * 1024) { "Bridge chunk too large" } + val job = clipboardCiphers[id] ?: throw IOException("Cipher closed") + if (job.generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + job.cipher.update(block)?.let { job.output.write(it) } + promise.resolve(null) + } catch (error: Exception) { promise.reject(if (error is java.io.InterruptedIOException) "CLIPBOARD_SUPERSEDED" else "CIPHER_ERROR", "Clipboard cipher interrupted", error) } + } + } + + @ReactMethod + fun finishClipboardCipher(id: String, promise: Promise) { + clipboardReads.execute { + val job = clipboardCiphers.remove(id) + try { + requireNotNull(job) { "Cipher closed" } + if (job.generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + job.output.write(if (job.decrypt) job.cipher.doFinal(job.tag) else job.cipher.doFinal()) + job.output.close() + val result = com.facebook.react.bridge.Arguments.createMap() + if (!job.decrypt) { + val tagBytes = ByteArray(16) + java.io.RandomAccessFile(job.file, "rw").use { file -> + file.seek(file.length() - 16) + file.readFully(tagBytes) + file.setLength(file.length() - 16) + } + result.putString("nonce", Base64.encodeToString(job.cipher.iv, Base64.NO_WRAP)) + result.putString("tag", Base64.encodeToString(tagBytes, Base64.NO_WRAP)) + } + if (job.generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + val stream = object : java.io.FileInputStream(job.file) { + override fun close() { try { super.close() } finally { deleteCipherFile(job.file) } } + } + attachmentStreams[id] = job.generation to stream + result.putString("id", id) + promise.resolve(result) + } catch (error: Exception) { + runCatching { job?.output?.close() } + job?.file?.let { deleteCipherFile(it) } + promise.reject(if (error is java.io.InterruptedIOException) "CLIPBOARD_SUPERSEDED" else "CIPHER_ERROR", "Clipboard authentication or cipher failed", error) + } + } + } + + @ReactMethod + fun abortClipboardCipher(id: String, promise: Promise) { + clipboardReads.execute { + val job = clipboardCiphers.remove(id) + try { job?.output?.close(); promise.resolve(null) } + catch (error: Exception) { promise.reject("CIPHER_ERROR", "Cannot close clipboard cipher", error) } + finally { job?.file?.let { deleteCipherFile(it) } } + } + } + + @ReactMethod + fun openClipboardBytes(uri: String, promise: Promise) { + val generation = clipboardReadGeneration.get() + clipboardReads.execute { + try { + attachmentStreams.entries.removeAll { (_, value) -> + if (value.first != generation) { value.second.close(); true } else false + } + if (generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + val stream = reactApplicationContext.contentResolver.openInputStream(Uri.parse(uri.trim())) + ?: throw IOException("Cannot open attachment") + val id = (++nextAttachmentStream).toString() + attachmentStreams[id] = generation to stream + promise.resolve(id) + } catch (error: Exception) { promise.reject(if (error is java.io.InterruptedIOException) "CLIPBOARD_SUPERSEDED" else "ERROR", "Cannot open attachment", error) } + } + } + + @ReactMethod + fun readClipboardBytes(id: String, promise: Promise) { + clipboardReads.execute { + try { + val (generation, stream) = attachmentStreams[id] ?: throw IOException("Attachment closed") + if (generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + val buffer = ByteArray(32 * 1024) + val count = stream.read(buffer) + if (generation != clipboardReadGeneration.get()) throw java.io.InterruptedIOException() + promise.resolve(if (count < 0) "" else Base64.encodeToString(buffer, 0, count, Base64.NO_WRAP)) + } catch (error: Exception) { + attachmentStreams.remove(id)?.second?.close() + promise.reject(if (error is java.io.InterruptedIOException) "CLIPBOARD_SUPERSEDED" else "ERROR", "Attachment read interrupted", error) + } + } + } + + @ReactMethod + fun closeClipboardBytes(id: String, promise: Promise) { + clipboardReads.execute { + try { attachmentStreams.remove(id)?.second?.close(); promise.resolve(null) } + catch (error: Exception) { promise.reject("ERROR", "Cannot close attachment", error) } + } + } + @ReactMethod fun getFileAsBase64(contentUri: String, promise: Promise) { + val generation = clipboardReadGeneration.get() try { + clipboardReads.execute { readFileAsBase64(contentUri, generation, promise) } + } catch (e: java.util.concurrent.RejectedExecutionException) { + promise.reject("ERROR", "Clipboard preparation has stopped", e) + } + } + + @ReactMethod(isBlockingSynchronousMethod = true) + fun cancelClipboardReadsSync(): Double = clipboardReadGeneration.incrementAndGet().toDouble() + + @ReactMethod(isBlockingSynchronousMethod = true) + fun isOwnClipboardImageSync(uri: String): Boolean = uri == ownClipboardImageUri + + private fun readFileAsBase64(contentUri: String, generation: Long, promise: Promise) { + try { + fun checkCurrent() { + if (generation != clipboardReadGeneration.get() || Thread.currentThread().isInterrupted) { + throw java.io.InterruptedIOException("Clipboard superseded") + } + } + checkCurrent() val trimmedUri = contentUri.trim() val uri = Uri.parse(trimmedUri) reactApplicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> - val bytes = inputStream.readBytes() - val base64String = Base64.encodeToString(bytes, Base64.DEFAULT) - promise.resolve(base64String) + val output = ByteArrayOutputStream() + Base64OutputStream(output, Base64.DEFAULT).use { encoder -> + val buffer = ByteArray(256 * 1024) + while (true) { + checkCurrent() + val count = inputStream.read(buffer) + if (count < 0) break + encoder.write(buffer, 0, count) + } + checkCurrent() + } + checkCurrent() + promise.resolve(output.toString(Charsets.US_ASCII.name())) } ?: run { promise.reject("ERROR", "Failed to open file stream. Invalid URI or file access denied. ${trimmedUri}") } + } catch (e: java.io.InterruptedIOException) { + promise.reject("CLIPBOARD_SUPERSEDED", "Clipboard superseded", e) } catch (e: Exception) { promise.reject("ERROR", "Failed to get file bytes: ${e.message}") } @@ -296,11 +579,18 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa } private fun writeAndroidClipboard(clipData: ClipData, contentType: String, promise: Promise) { + val writeId = java.util.UUID.randomUUID().toString() try { + val extras = clipData.description.extras?.let { android.os.PersistableBundle(it) } + ?: android.os.PersistableBundle() + extras.putString(ClipboardObservations.OWN_WRITE_KEY, writeId) + clipData.description.extras = extras + ClipboardObservations.expectOwnWrite(writeId) val clipboard = reactApplicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboard.setPrimaryClip(clipData) promise.resolve("$contentType copied to clipboard.") } catch (error: Exception) { + ClipboardObservations.forgetOwnWrite(writeId) // Android can wrap Binder failures inside RuntimeException. val causes = generateSequence(error as Throwable) { it.cause }.toList() val sizeError = causes.firstOrNull { it is TransactionTooLargeException } @@ -343,6 +633,7 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa @ReactMethod fun copyBase64ImageToClipboardUsingCache(base64String: String, promise: Promise) { + val generation = clipboardReadGeneration.get() try { // Clear existing cached images to prevent accumulation clearImageCacheInternal() @@ -353,6 +644,10 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa promise.reject("INVALID_IMAGE", "The provided bytes do not represent a valid image.") return } + if (generation != clipboardReadGeneration.get()) { + promise.reject("CLIPBOARD_SUPERSEDED", "Clipboard superseded") + return + } // Create a unique file in the cache directory val fileName = "clipboard_image_${System.currentTimeMillis()}.png" @@ -369,6 +664,12 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa // Copy the URI to clipboard val clipData = ClipData.newUri(reactApplicationContext.contentResolver, "Image", imageUri) + if (generation != clipboardReadGeneration.get()) { + cacheFile.delete() + promise.reject("CLIPBOARD_SUPERSEDED", "Clipboard superseded") + return + } + ownClipboardImageUri = imageUri.toString() writeAndroidClipboard(clipData, "image", promise) } catch (e: Exception) { @@ -534,6 +835,16 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa override fun onCatalystInstanceDestroy() { + stompSockets.shutdown() + clipboardReadGeneration.incrementAndGet() + // Queued jobs must run their cancellation checks and settle promises. + clipboardReads.execute { + attachmentStreams.values.forEach { runCatching { it.second.close() } } + attachmentStreams.clear() + clipboardCiphers.values.forEach { runCatching { it.output.close() }; deleteCipherFile(it.file) } + clipboardCiphers.clear() + } + clipboardReads.shutdown() asyncBridge.disconnect() } } diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeStompSocket.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeStompSocket.kt new file mode 100644 index 000000000..b345a7673 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeStompSocket.kt @@ -0,0 +1,176 @@ +package com.clipcascade + +import android.os.SystemClock +import android.util.Base64 +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.facebook.react.modules.network.ForwardingCookieHandler +import java.io.IOException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import okio.ByteString.Companion.toByteString +import org.json.JSONObject + +/** A STOMP byte stream with bounded OkHttp buffering, not a new clipboard protocol. */ +internal class NativeStompSocket(private val context: ReactApplicationContext) { + private val sockets = ConcurrentHashMap() + private val cookies = ForwardingCookieHandler(context) + + fun connect(id: Int, url: String, protocols: ReadableArray) { + val connection = Connection(id) + sockets[id] = connection + try { + val builder = OkHttpClient.Builder().connectTimeout(10, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS).readTimeout(0, TimeUnit.MILLISECONDS) + ServerTls.applyTo(builder) + val request = Request.Builder().url(url) + val address = request.build().url + cookies.get(address.toUri(), emptyMap()).forEach { (key, values) -> + if (key.equals("Cookie", true)) request.addHeader("Cookie", values.joinToString("; ")) + } + val origin = address.newBuilder().username("").password("").encodedPath("/") + .query(null).fragment(null).build().toString().removeSuffix("/") + request.header("Origin", origin) + val versions = (0 until protocols.size()).mapNotNull { protocols.getString(it) } + if (versions.isNotEmpty()) request.header("Sec-WebSocket-Protocol", versions.joinToString(",")) + connection.socket = builder.build().newWebSocket(request.build(), connection) + } catch (error: Exception) { + connection.failed(error) + } + } + + fun send(id: Int, frame: String, terminated: Boolean, promise: Promise, binary: Boolean = false, drain: Boolean = true) { + val connection = sockets[id] + if (connection == null) { + promise.reject("STOMP_CLOSED", "STOMP connection is closed") + return + } + try { + connection.writer.execute { + try { + connection.write(frame, terminated, binary, drain) + promise.resolve(null) + } catch (error: Exception) { + promise.reject("STOMP_SEND_FAILED", error.message, error) + connection.close(1011, "STOMP send failed", true) + } + } + } catch (error: java.util.concurrent.RejectedExecutionException) { + promise.reject("STOMP_CLOSED", "STOMP connection is closed", error) + } + } + + fun close(id: Int, code: Int, reason: String, force: Boolean) { + sockets[id]?.close(code, reason, force) + } + + fun shutdown() { + sockets.values.toList().forEach { it.close(1000, "Service stopped", true) } + } + + private inner class Connection(private val id: Int) : WebSocketListener() { + @Volatile var socket: WebSocket? = null + @Volatile private var open = false + @Volatile private var closing = false + val writer = Executors.newSingleThreadExecutor { task -> + Thread(task, "StompWriter-$id").apply { isDaemon = true } + } + + private fun event(type: String, data: String = "", code: Int = 0) { + if (!context.hasActiveReactInstance()) return + val payload = Arguments.createMap().apply { + putInt("id", id); putString("type", type) + // Keep STOMP NUL bytes out of bridge strings in both directions. + putString("data", if (type == "message") JSONObject.quote(data) else data) + putInt("code", code) + } + context.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + .emit("CLIPCASCADE_STOMP_SOCKET", payload) + } + + override fun onOpen(webSocket: WebSocket, response: Response) { + socket = webSocket + if (closing) { webSocket.cancel(); return } + open = true + event("open") + } + + override fun onMessage(webSocket: WebSocket, text: String) { event("message", text) } + override fun onMessage(webSocket: WebSocket, bytes: okio.ByteString) { + // STOMP consumes a continuous stream; keep binary bridge strings bounded. + var offset = 0 + while (offset < bytes.size) { + val end = minOf(offset + 32 * 1024, bytes.size) + event("binary", Base64.encodeToString(bytes.substring(offset, end).toByteArray(), Base64.NO_WRAP)) + offset = end + } + } + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { webSocket.close(code, reason) } + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { finished(code, reason) } + override fun onFailure(webSocket: WebSocket, error: Throwable, response: Response?) { failed(error) } + + fun failed(error: Throwable) { + if (!closing) event("error", error.message ?: "STOMP connection failed") + finished(1006, error.message ?: "STOMP connection failed") + } + + private fun finished(code: Int, reason: String) { + open = false + closing = true + if (sockets.remove(id, this)) event("close", reason, code) + // Queued calls must settle their promises, rather than being discarded. + writer.shutdown() + } + + fun close(code: Int, reason: String, force: Boolean) { + closing = true + open = false + if (force) socket?.cancel() else socket?.close(code, reason) + if (socket == null) finished(code, reason) + } + + fun write(frame: String, terminated: Boolean, binary: Boolean, drain: Boolean) { + val target = socket ?: throw IOException("STOMP connection is closed") + val bytes = if (binary) Base64.decode(frame, Base64.NO_WRAP) else frame.toByteArray(Charsets.UTF_8) + var previous = target.queueSize() + var lastProgress = SystemClock.elapsedRealtime() + fun awaitCapacity(required: Long) { + while (true) { + if (!open || closing) throw IOException("STOMP connection closed during send") + val queued = target.queueSize() + if (queued < previous) lastProgress = SystemClock.elapsedRealtime() + previous = queued + if (queued + required <= 1024 * 1024) return + if (SystemClock.elapsedRealtime() - lastProgress >= 30_000) { + throw IOException("STOMP connection stopped draining for 30 seconds") + } + Thread.sleep(20) + } + } + var offset = 0 + while (offset < bytes.size) { + val count = minOf(16 * 1024, bytes.size - offset) + awaitCapacity(count.toLong()) + if (!target.send(bytes.toByteString(offset, count))) throw IOException("STOMP send rejected") + offset += count + } + if (terminated) { + awaitCapacity(1) + if (!target.send(byteArrayOf(0).toByteString())) throw IOException("STOMP send rejected") + } + // Resolve only after the native transport has drained this frame. + while (drain && target.queueSize() > 0) { + awaitCapacity(1024L * 1024) + } + } + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/PendingShares.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/PendingShares.kt index a89bcbe48..8c19ddea0 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/PendingShares.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/PendingShares.kt @@ -6,12 +6,12 @@ import com.facebook.react.bridge.WritableMap /** One short-lived startup slot, not an offline/retry queue. */ object PendingShares { private const val STARTUP_TIMEOUT_MS = 10_000L - private data class Entry(val content: ClipboardContent, val createdAt: Long) + private data class Entry(val content: ClipboardContent, val createdAt: Long, val clipboardBaselineRevision: String) private var pending: Entry? = null @Synchronized - fun add(content: ClipboardContent) { - pending = Entry(content, SystemClock.elapsedRealtime()) + fun add(content: ClipboardContent, clipboardBaselineRevision: String) { + pending = Entry(content, SystemClock.elapsedRealtime(), clipboardBaselineRevision) } @Synchronized @@ -19,6 +19,7 @@ object PendingShares { val entry = pending ?: return null pending = null return entry.content.toMap().apply { + putString("clipboardBaselineRevision", entry.clipboardBaselineRevision) putBoolean("expired", SystemClock.elapsedRealtime() - entry.createdAt >= STARTUP_TIMEOUT_MS) } } diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ProtectedStorage.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ProtectedStorage.kt new file mode 100644 index 000000000..43079602f --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ProtectedStorage.kt @@ -0,0 +1,70 @@ +package com.clipcascade + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** Sensitive AsyncStorage rows contain only authenticated ciphertext after migration. */ +internal object ProtectedStorage { + private const val KEY_ALIAS = "ClipCascade.storage.v1" + private const val PREFIX = "keystore:v1:" + private const val IV_LENGTH = 12 + private const val TAG_BITS = 128 + private val keys = setOf("password", "hashed_password", "csrf_token") + + @Synchronized + fun read(storage: AsyncStorageBridge, name: String): String? { + require(name in keys) + val stored = storage.getValue(name) ?: return null + if (stored.isEmpty()) return stored + if (!stored.startsWith(PREFIX)) { + // Replace the legacy value in the same SQLite row only after encryption succeeds. + write(storage, name, stored) + return stored + } + + val bytes = Base64.decode(stored.removePrefix(PREFIX), Base64.NO_WRAP) + require(bytes.size >= IV_LENGTH + TAG_BITS / 8) + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, key(create = false), GCMParameterSpec(TAG_BITS, bytes, 0, IV_LENGTH)) + cipher.updateAAD(name.toByteArray(Charsets.UTF_8)) + return cipher.doFinal(bytes, IV_LENGTH, bytes.size - IV_LENGTH).toString(Charsets.UTF_8) + } + + @Synchronized + fun write(storage: AsyncStorageBridge, name: String, value: String) { + require(name in keys) + val stored = if (value.isEmpty()) value else { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, key(create = true)) + cipher.updateAAD(name.toByteArray(Charsets.UTF_8)) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + check(cipher.iv.size == IV_LENGTH) + PREFIX + Base64.encodeToString(cipher.iv + encrypted, Base64.NO_WRAP) + } + check(storage.setValue(name, stored)) { "Could not save protected data" } + } + + private fun key(create: Boolean): SecretKey { + val keystore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (keystore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + check(create) { "Protected storage key is unavailable" } + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { + init( + KeyGenParameterSpec.Builder( + KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build() + ) + generateKey() + } + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt index 81d4eb689..c3bb3bcae 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ScheduleService.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.work.WorkerParameters +import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import androidx.core.app.NotificationCompat @@ -22,13 +23,44 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout private const val TAG = "ScheduleService" private const val NOTIFICATION_CHANNEL_ID = "clipcascade_foreground_service_stopped_running" private const val NOTIFICATION_ID = 1 + private var activityVisible = false + private var notificationGeneration = 0L + @Synchronized + fun onActivityStarted(context: Context) { + activityVisible = true + removeNotificationIfPresent(context) + } + + @Synchronized + fun onActivityStopped() { + activityVisible = false + } + + @Synchronized fun removeNotificationIfPresent(context: Context) { + // A check that began before this dismissal can no longer post an alert. + notificationGeneration++ val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.cancel(NOTIFICATION_ID) } - fun hasNotificationPermission(context: Context): Boolean { + @Synchronized + private fun beginNotificationCheck(): Long? = + if (activityVisible) null else notificationGeneration + + @Synchronized + private fun postNotification( + generation: Long, + notificationManager: NotificationManager, + notification: Notification + ) { + if (!activityVisible && generation == notificationGeneration) { + notificationManager.notify(NOTIFICATION_ID, notification) + } + } + + private fun hasNotificationPermission(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( context, @@ -42,6 +74,7 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout // init override suspend fun doWork(): Result { + val generation = beginNotificationCheck() ?: return Result.success() // show notification if foreground service is not running try { @@ -49,7 +82,7 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout val bridgeData = AsyncStorageBridge(applicationContext) if(enableForegroundService(bridgeData)) { if(!foregroundServiceIsActive(bridgeData) && enableForegroundService(bridgeData)) { - showNotificationIfNotPresent() + showNotificationIfNotPresent(generation) } else { removeNotificationIfPresent(applicationContext) } @@ -83,7 +116,7 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout return false } - private fun showNotificationIfNotPresent() { + private fun showNotificationIfNotPresent(generation: Long) { val notificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -116,7 +149,7 @@ class ScheduleService(context: Context, workerParams: WorkerParameters) : Corout .setAutoCancel(true) .build() - notificationManager.notify(NOTIFICATION_ID, notification) + postNotification(generation, notificationManager, notification) } } diff --git a/ClipCascade_Server/API.md b/ClipCascade_Server/API.md index 9d581f26b..f580c5d38 100644 --- a/ClipCascade_Server/API.md +++ b/ClipCascade_Server/API.md @@ -7,9 +7,11 @@ 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. +Use `/server-version` to identify the running server; deployed builds may differ +from this tree. There is no separately versioned API or generated OpenAPI contract. +Optional transfer capabilities are advertised per connection, and P2S provides a +recipient-discovery endpoint. Probe capabilities instead of assuming support from +a version number. Legacy JSON transfers remain supported. ## Contents @@ -38,8 +40,9 @@ OpenAPI contract. Probe the running server instead of assuming its mode or limit 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. +5. Start with the legacy JSON format, or negotiate the optional CCB1 binary format + described below. Encode and optionally encrypt the complete content before + wrapping it in its transport 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. @@ -52,8 +55,8 @@ OpenAPI contract. Probe the running server instead of assuming its mode or limit | 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 | +| Recipient selection | Same-user sessions; optional recipient selection and receive-size filtering | Client selects connected peers of that username, respecting advertised receive limits | +| Sender receives own update | Legacy JSON defaults to yes; opt-out supported; binary never echoes | 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 | @@ -176,9 +179,9 @@ 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. +- `POST /logout` ends the current HTTP session and closes its STOMP or P2P + signaling WebSockets. Other logins stay connected. Clients should still + clean up their local connections, including direct WebRTC peer connections. - `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 @@ -273,6 +276,7 @@ All routes here require a logged-in user, including the configuration endpoints. | 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 | `/clipbinary/recipients` | P2S only. `{"format":"ccb1","recipients":[{"id":"","binary":true}]}` for the authenticated username; `Cache-Control: no-store`. See [P2S capabilities](#p2s-transfer-capabilities-and-recipient-discovery). | | 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. | @@ -284,7 +288,9 @@ All `/admin/*` routes below require an authenticated `ADMIN`. Mutations use | Method | Path | Input / response | | --- | --- | --- | -| GET | `/admin/advance` | HTML showing server configuration; a non-admin is redirected to `/`. | +| GET | `/admin/advance` | Redirects admins to `/#advanced`; a non-admin is redirected to `/`. | +| GET | `/admin/advance/fragment` | Admin-only HTML fragment containing Advanced dashboard tools and server configuration. Non-admins receive `403`. | +| GET | `/admin/default-password-status` | JSON booleans `usingDefaultPassword` and `showPrompt`. Reading the status consumes the prompt for the current login when the default password is active. Non-admins receive `403`. | | 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: `. | @@ -303,7 +309,7 @@ All `/admin/*` routes below require an authenticated `ADMIN`. Mutations use | 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. | +| PUT | `/admin/unlock-user` | JSON `{"username":"alice"}`. Clears user and per-IP lockout timers while retaining the 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. | @@ -468,6 +474,11 @@ STOMP versions `1.0,1.1`). A connection proceeds as follows: 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. +The server accepts `SEND` to `/app/cliptext` (legacy JSON) or `/app/clipbinary` +(negotiated CCB1 binary), and `SUBSCRIBE` only to `/user/queue/cliptext`. +Other send or subscription destinations are rejected. Both formats arrive on the +same subscription; distinguish binary messages by `content-type:application/octet-stream`. + 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: @@ -506,8 +517,10 @@ 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`; +By default, the server emits a legacy JSON envelope to connected, subscribed +sessions of the **same username, including the sender**. Optional headers can +disable self-echo, select recipients, or advertise a receive-size limit; see below. +For legacy JSON 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 @@ -520,6 +533,64 @@ The simple broker advertises `heart-beat:20000,0`. With the client's pair is negotiated, respect it. External broker deployments can differ. Use scheduling/network tolerance when detecting missed heartbeats. +#### P2S transfer capabilities and recipient discovery + +These optional **STOMP CONNECT headers** are connection-scoped, not HTTP upgrade +headers. Clients omitting them retain legacy behavior. + +| Header | Value / behavior | +| --- | --- | +| `x-clipcascade-binary` | `ccb1` advertises binary receive support and permits recipient-targeted sends. Advertise it only if you can also receive legacy JSON. | +| `x-clipcascade-recipient-updates` | `1` requests JSON recipient snapshots on the clipboard subscription. Independent of binary support. | +| `x-clipcascade-no-self-echo` | `true` suppresses this sender's legacy JSON echo. Binary sends always exclude the sender. | +| `x-clipcascade-receive-limit` | Decimal integer bytes from `0` through `9007199254740991`, or `unlimited`. Missing/invalid values advertise no usable limit. Reconnect to change this header's value. | + +Subscribe normally to `/user/queue/cliptext`. Opted-in clients receive a full +snapshot after subscription/membership changes and approximately every 20 seconds: + +```json +{"_cc_control":"relay-recipients","version":1,"epoch":"","revision":1,"format":"ccb1","recipients":[{"id":"","binary":true},{"id":"","binary":false}]} +``` + +Consume this reserved control before looking for clipboard fields or applying +receive-clipboard preferences. It contains no clipboard data or encryption keys. +On each connection, clear the old snapshot. Within that connection, accept only +the same nonempty epoch and increasing safe-integer revisions. Official clients +expire snapshots after 60 seconds. Validate the recipient array: at most 256 +unique IDs, each matching `[A-Za-z0-9_-]{1,128}`, with boolean `binary` values. +Oversized/invalid recipient lists invalidate the cached plan. The server can send +257 entries to signal that the cache is too large to use. + +When no usable pushed snapshot exists, `GET /clipbinary/recipients` returns the +same `format`/`recipients` fields without the control/version/epoch/revision fields. +It lists the user's connected STOMP sessions; the pushed snapshot tracks clipboard +subscriptions. Binary support is recorded once a binary-capable session subscribes. +These snapshots are not delivery guarantees: sessions can join or leave afterward. +If discovery is unavailable, unsupported, or invalid, send legacy JSON to +`/app/cliptext` without a recipient header. Official clients bound the HTTP lookup +to about three seconds; older servers need no changes to accept this fallback. + +For a valid plan, partition recipients by `binary`. Send CCB1 only to binary +recipients and legacy JSON only to the others. On each SEND, set +`x-clipcascade-recipients` to that group's comma-separated session IDs; do not +send an empty group/header. This header requires the sender's CONNECT binary +opt-in, accepts at most 256 IDs and 32768 characters, and is required on +`/app/clipbinary`. Target IDs are restricted to the authenticated user's sessions. +Binary delivery additionally excludes the sender and non-binary sessions. Avoid +also broadcasting legacy JSON to everyone, which would duplicate binary delivery. +If binary content cannot be represented within the header/fragment bounds below, +official clients fall back to legacy JSON for those recipients too. + +Include `metadata.contentSizeBytes` when the original size is known: UTF-8 text +bytes, encoded image file bytes, or the sum of file-content bytes, excluding +headers, Base64, JSON, and encryption overhead. It is optional for legacy JSON and +required for CCB1, with integer range `0..9007199254740991`. The relay uses a valid +size to skip recipients whose advertised limit is smaller. A legacy sender still +gets its own JSON echo unless it opted out or explicitly excluded itself via +recipient selection; this preserves older Android clients' send completion behavior. +Without a valid size, the relay cannot perform size-based filtering. Receivers +must check actual decoded size too; metadata is sender-supplied and unauthenticated. + ### P2P: signaling over `/p2psignaling` This socket exchanges JSON text messages without STOMP or NUL terminators. @@ -607,18 +678,25 @@ 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`: +Legacy messages on `cliptext` are JSON strings. CCB1-capable peers can also exchange +binary messages after negotiation. Ignore the following JSON control message +before looking for `payload`, `type`, or `metadata` (also accept the older minimal +form `{"_cc_keepalive":true}`): ```json -{"_cc_keepalive":true} +{"_cc_keepalive":true,"capabilities":{"version":1,"transferFormats":["ccb1"],"receiveLimitBytes":null}} ``` 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. +ping/pong on the signaling connection. Current clients also send capabilities when +the data channel opens. Use binary only after that peer advertises version `1` +with `ccb1` in `transferFormats`; unknown/older peers receive legacy JSON. +`receiveLimitBytes` is `null` for unlimited or a safe nonnegative integer. Filter +each peer independently, update its limit from later controls, and clear its +capabilities on channel replacement/disconnection. -The clipboard envelope is the same as P2S, with fragmentation metadata: +The legacy JSON clipboard envelope is the same as P2S, with fragmentation metadata: ```json { @@ -629,7 +707,8 @@ The clipboard envelope is the same as P2S, with fragmentation metadata: "isFragmented":false, "index":0, "totalFragments":1, - "combinedRawPayloadSizeInBytes":5 + "combinedRawPayloadSizeInBytes":5, + "contentSizeBytes":5 } } ``` @@ -640,9 +719,10 @@ The clipboard envelope is the same as P2S, with fragmentation metadata: | `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 | +| `combinedRawPayloadSizeInBytes` | integer | Legacy JSON: UTF-8 size of the complete serialized plaintext payload before encryption. CCB1: size of the complete inner CCB1 content container before encryption. Excludes the outer envelope and is not the original file byte count. | +| `contentSizeBytes` | integer, optional for legacy JSON | Original text/image/file-content bytes, excluding serialization/encryption overhead; required for CCB1. | -Sender algorithm: +Legacy JSON sender algorithm: 1. Serialize the content into the payload string described below. Record its UTF-8 length for `combinedRawPayloadSizeInBytes`. @@ -658,7 +738,7 @@ Sender algorithm: 5. Use data-channel buffered-amount/backpressure support to avoid unbounded queued sends. Finish one transfer before starting another for best interoperability. -Receiver algorithm: +Legacy JSON receiver algorithm: 1. Drop keepalive messages. Check types, count/index bounds, transfer identity, and local byte/count limits before allocating buffers. @@ -676,8 +756,9 @@ For compatibility, send ordered fragments: their receive logic expects completio 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. +With encryption disabled, the legacy string fragmenter emits no fragments for an +empty string, so do not depend on an empty legacy P2P payload to clear other +clients' clipboards. CCB1 has an inner container even for empty text and can carry it. #### Signaling liveness and limits @@ -697,22 +778,24 @@ 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. +validation. The P2S server relays legacy payload strings or CCB1 bytes and does not +encrypt, decrypt, inspect image bytes, or save uploaded files. It validates the +binary outer header and recipient selection, but cannot authenticate encrypted +content. P2P signaling does not see clipboard content at all. -### Common envelope +### Legacy JSON 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 | +| `metadata` | object or null, optional | Original-content size and/or 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. +compression field, timestamp, MIME field, or sender-device ID in the legacy JSON +envelope. CCB1 has an explicit encryption flag, as described below. Unknown +types/custom metadata have no official client behavior. ### Plaintext content formats @@ -730,14 +813,15 @@ Plaintext file envelope containing `hello.txt` with five bytes `Hello`: 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. +Official desktop and Android senders rename duplicate basenames, for example +`report.txt` and `report (1).txt`, avoiding case-insensitive name collisions. 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 +### Encryption derivation and legacy 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 @@ -819,7 +903,7 @@ def decrypt_payload(payload, key): # body = json.dumps({"payload": encrypt_payload("Hello", key), "type": "text"}) ``` -The complete transformation order is: +For legacy JSON, the complete transformation order is: ```text Send: content → serialize to string → encrypt if enabled → fragment if P2P @@ -867,11 +951,142 @@ Expected inner encryption object (JSON key order/whitespace may differ): Serialize this object into a string for `payload`; do not send it as an object. +### CCB1 binary content, encryption, and framing + +CCB1 is optional. A legacy-only client can continue using the JSON conventions +above without opting into binary or recipient controls. New clients must retain +legacy support for older clients and servers; the relay does not transcode formats. + +Both the outer transport envelope and inner content container use this byte layout: + +| Bytes | Meaning | +| --- | --- | +| `0..3` | ASCII magic `CCB1` (`43 43 42 31` in hexadecimal) | +| `4..7` | Unsigned 32-bit big-endian length of the UTF-8 JSON header | +| Next `headerLength` bytes | JSON object, at most 65536 bytes | +| Remaining bytes | Opaque payload bytes | + +Reject a wrong magic, truncated prefix/header, non-object JSON header, or header +length above 65536. Lengths count encoded bytes, not characters. No NUL terminator +belongs to CCB1 itself; STOMP still has its own frame terminator. + +**Inner content container:** serialize original content without Base64: + +| Type | Inner header | Inner payload bytes | +| --- | --- | --- | +| `text` | `{"type":"text","files":null}` | UTF-8 text; preserve leading `U+FEFF`, line breaks, and empty strings | +| `image` | `{"type":"image","files":null}` | Encoded image file bytes, not raw pixels | +| `files` | `{"type":"files","files":[["hello.txt",5],["empty.txt",0]]}` | File bytes concatenated in exactly the header's file-entry order | + +File lengths are nonnegative integers. Reject duplicate or empty names, `/`, `\\`, +NUL, `.` and `..`, lengths exceeding the remaining content, and trailing content +not described by the file list. Empty files are valid. Require the inner `type` +to match the outer envelope's `type`. Check the actual content byte total against +the local receive limit, independently of the advertised metadata size. + +**Encryption:** derive the same 32-byte key described above. If enabled, encrypt +the **entire inner CCB1 container once** with AES-256-GCM, no AAD, a fresh nonce, +and a 16-byte authentication tag. Replace the inner container with these bytes: + +```text +one-byte nonce length N | N nonce bytes | 16 tag bytes | ciphertext bytes +``` + +Official receivers accept nonce lengths from 8 through 32 bytes. Verify the tag +before parsing or exposing decrypted content. Unlike legacy encrypted JSON, there +is no Base64 or nonce/ciphertext/tag JSON object. Encrypting the inner container +authenticates the content type and file boundaries. Outer routing/size metadata +remains outside that authenticated plaintext. + +**Outer transport envelope:** its JSON header contains: + +```json +{"type":"text","metadata":{"contentSizeBytes":5},"encrypted":false} +``` + +`type` must be `text`, `image`, or `files`; `encrypted` must be a boolean matching +the receiver's encryption setting. `metadata.contentSizeBytes` is required and is +the original content byte count, excluding both containers and encryption overhead. +The outer payload is the complete inner container or encrypted bytes above, except +when carrying a P2P fragment. The envelope header is always visible to the relay. + +**P2S:** send the complete outer CCB1 envelope as the byte body of one STOMP SEND +to `/app/clipbinary`, with `content-type:application/octet-stream`, the exact body +byte `content-length`, and `x-clipcascade-recipients` as described earlier. Use a +byte-safe STOMP implementation because the body can contain NUL or non-UTF-8 data. +The server forwards the bytes with the same content type to the existing +`/user/queue/cliptext` subscription. It rejects fragmented relay envelopes; P2S has +no application-level reassembly. WebSocket messages can carry portions of the +STOMP stream, so receivers must parse across message boundaries. + +**P2P:** split the complete inner/encrypted bytes into chunks of at most 15360 +bytes, then wrap **each chunk** in an outer CCB1 envelope with the full metadata: + +```json +{"type":"text","encrypted":false,"metadata":{"id":"","isFragmented":false,"index":0,"totalFragments":1,"combinedRawPayloadSizeInBytes":42,"contentSizeBytes":5}} +``` + +The `42` above is illustrative: calculate `combinedRawPayloadSizeInBytes` from +the actual inner CCB1 container **before encryption**. JSON header serialization +affects this value. All fragments share the same ID, type, encryption flag, count, +and original-content size. `totalFragments` is `1..65536`; `index` is zero-based; +`isFragmented` must equal `totalFragments > 1`. Even a single-fragment binary P2P +message carries this metadata. Send binary data-channel messages in index order. +Use independent backpressure for each recipient so a stalled peer does not block +the others. Cancel superseded local work rather than mixing revisions. + +On receive, require fragment zero to start a transfer, consistent metadata and +sending peer, and consecutive indices. Bound allocation and accumulated bytes; +official binary reassembly allows at most `contentSizeBytes + 65536 + 64` bytes +for the inner header and encryption overhead. Reassemble the opaque payload bytes +before decrypting or parsing the inner container. Expire abandoned transfers and +ignore tails of canceled transfers. If a sender's inner header exceeds 65536 bytes +or binary fragmentation would exceed 65536 chunks, use legacy JSON for that peer. + +Minimal encoder using the encryption helpers/key derivation above: + +```python +import json +import struct +from Crypto.Cipher import AES + + +def pack_ccb1(header, payload): + encoded = json.dumps(header, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + if len(encoded) > 65536: + raise ValueError("CCB1 header too large") + return b"CCB1" + struct.pack(">I", len(encoded)) + encoded + payload + + +def ccb1_text_frame(text, key=None): + content = text.encode("utf-8") + raw = pack_ccb1({"type": "text", "files": None}, content) + if key is not None: + cipher = AES.new(key, AES.MODE_GCM) + ciphertext, tag = cipher.encrypt_and_digest(raw) + raw = bytes([len(cipher.nonce)]) + cipher.nonce + tag + ciphertext + return pack_ccb1({ + "type": "text", + "metadata": {"contentSizeBytes": len(content)}, + "encrypted": key is not None, + }, raw) + + +# After capability discovery, use this as the binary STOMP body, with recipient headers. +# frame = ccb1_text_frame("Hello", derive_key(username, raw_password)) +``` + ### 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 +The server has no content deduplication or conflict resolution. New official +clients disable their JSON self-echo; binary sends never echo. Older servers can +still echo JSON, so official clients also track their own encrypted nonce/tag +envelopes to distinguish echoes without retaining clipboard contents. A legacy +client omitting the no-self-echo opt-in should continue handling its own echo. + +Official clients track a hash of the content type, file boundaries where relevant, +and original content bytes 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 @@ -1011,9 +1226,12 @@ 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, +JSON string limit to **twice the base size** to allow encoding overhead. The native +Tomcat blocking-write stall timeout is 60000 ms. Spring's whole-send time limit +is `2147483647` ms (about 24.8 days), while its per-session send-buffer protection +remains enabled. These are separate from any broker/proxy timeouts. The server +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 @@ -1026,6 +1244,13 @@ is conditional and does not configure the P2P signaling endpoint; do not infer i maximum SDP size from `/max-size`. Apply your own transfer/buffer limits and account for runtime, proxy, and WebRTC message limits. +Current clients measure local limits against original content bytes, not legacy +Base64/JSON or CCB1 container sizes. They advertise receive limits using STOMP +CONNECT headers in P2S and data-channel capability controls in P2P. Legacy peers +may still enforce the serialized `combinedRawPayloadSizeInBytes` value and reject +content below the new original-content limit because of encoding overhead. Preserve +that legacy size field's representation-specific meaning for interoperability. + Client-relevant server settings (defaults from this tree): | Environment variable | Default | Effect | @@ -1043,7 +1268,7 @@ Client-relevant server settings (defaults from this tree): | `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_MAX_USER_ACCOUNTS` | `-1` | Regular user account cap, excluding the administrator. `-1` is 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 | @@ -1110,7 +1335,14 @@ These are implementation acceptance checks, not claims of automated test coverag - [ ] 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. + survive legacy JSON/Base64 and CCB1, including empty files and duplicate names. +- [ ] Leading `U+FEFF`, Unicode, line breaks, and empty binary text survive unchanged. +- [ ] Old clients/servers use legacy fallback; mixed recipient groups receive one + representation each; binary/control messages never reach unopted-in clients. +- [ ] Recipient snapshots reset on reconnect, expire correctly, and fall back safely + when discovery is unsupported or lists are invalid/oversized. +- [ ] Advertised receive limits filter peers independently; missing size metadata + does not bypass the receiver's actual-content size check. - [ ] 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. @@ -1136,6 +1368,8 @@ When changing the protocol, update this guide alongside these sources: - [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) +- [Binary relay endpoint and validation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/BinaryClipboardController.java) +- [P2S capabilities, recipient snapshots, and delivery](ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/P2SDeliveryService.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) @@ -1143,7 +1377,11 @@ When changing the protocol, update this guide alongside these sources: - [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 CCB1 codec](../ClipCascade_Desktop/src/utils/binary_protocol.py) +- [Desktop recipient snapshot cache](../ClipCascade_Desktop/src/utils/relay_recipients.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) +- [Mobile CCB1 codec](../ClipCascade_Mobile/src/BinaryProtocol.js) +- [Browser CCB1 codec](ClipCascade_Backend/src/main/resources/static/assets/js/binary-protocol.js) diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/CustomAuthenticationSuccessHandler.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/CustomAuthenticationSuccessHandler.java index 02d7cdd3e..fe5a336e4 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/CustomAuthenticationSuccessHandler.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/CustomAuthenticationSuccessHandler.java @@ -8,6 +8,7 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import com.acme.clipcascade.constants.ServerConstants; import com.acme.clipcascade.model.IpAttemptDetails; import com.acme.clipcascade.service.BruteForceProtectionService; import com.acme.clipcascade.service.FacadeUserService; @@ -37,6 +38,8 @@ public void onAuthenticationSuccess( // save ip details to the database facadeUserService.setLoginDetails(authentication.getName(), ipDetails); + request.getSession().removeAttribute(ServerConstants.DEFAULT_PASSWORD_PROMPT_SHOWN); + // Redirect to the home page response.sendRedirect("/"); } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java index acfe298af..9a05b45ba 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java @@ -31,6 +31,7 @@ public P2PWebSocketConfig( @Override public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) { registry.addHandler(liveWebSockets.decorate(p2pWebSocketHandler), "/p2psignaling") + .addInterceptors(liveWebSockets.sessionHandshakeInterceptor()) .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java index a55ac04e6..c85c6b834 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java @@ -15,13 +15,16 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler; import org.springframework.security.web.session.HttpSessionEventPublisher; import com.acme.clipcascade.service.BruteForceProtectionService; import com.acme.clipcascade.service.FacadeUserService; +import com.acme.clipcascade.service.LiveWebSocketService; @Configuration @EnableWebSecurity public class SecurityConfiguration { + private static final String LOGOUT_LOGIN_ID = SecurityConfiguration.class.getName() + ".logoutLoginId"; private final UserDetailsService userDetailsService; // <- Spring Security UserDetailsService private final BCryptPasswordEncoder bCryptPasswordEncoder; @@ -55,7 +58,10 @@ public HttpSessionEventPublisher httpSessionEventPublisher() { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry, + LiveWebSocketService liveWebSockets) throws Exception { + var logoutRedirect = new SimpleUrlLogoutSuccessHandler(); + logoutRedirect.setDefaultTargetUrl("/login?logout"); return http .authorizeHttpRequests((authorize) -> authorize .requestMatchers( @@ -79,7 +85,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistr facadeUserService))) // <- Custom authentication success handler .logout(logout -> logout .logoutUrl("/logout") // The URL to submit a logout request - .logoutSuccessUrl("/login?logout")) // Where to go after successful logout + .addLogoutHandler((request, response, authentication) -> { + var session = request.getSession(false); + if (session != null) { + request.setAttribute(LOGOUT_LOGIN_ID, session.getId()); + } + }) + .logoutSuccessHandler((request, response, authentication) -> { + // Spring Security has invalidated the HTTP session by this point. + if (request.getAttribute(LOGOUT_LOGIN_ID) instanceof String loginId) { + liveWebSockets.disconnectLogin(loginId); + } + logoutRedirect.onLogoutSuccess(request, response, authentication); + })) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) // Always create a new session .maximumSessions(-1) // Allow unlimited sessions diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java index 016274416..d9c5504df 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java @@ -4,30 +4,98 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.Message; +import org.springframework.messaging.MessageChannel; +import org.springframework.messaging.MessageHandler; +import org.springframework.messaging.support.ExecutorChannelInterceptor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.messaging.simp.config.ChannelRegistration; import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.support.MessageHeaderAccessor; import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; import org.springframework.web.socket.config.annotation.StompEndpointRegistry; import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.adapter.NativeWebSocketSession; +import org.springframework.web.socket.handler.WebSocketHandlerDecorator; +import org.springframework.web.socket.handler.WebSocketSessionDecorator; import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; +import jakarta.websocket.Session; +import org.apache.tomcat.websocket.Constants; + import com.acme.clipcascade.constants.ServerConstants; import com.acme.clipcascade.service.LiveWebSocketService; +import com.acme.clipcascade.service.P2SDeliveryService; @Configuration @EnableWebSocketMessageBroker @ConditionalOnProperty(prefix = "app.p2p", name = "enabled", havingValue = "false", matchIfMissing = false) public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { + private static final long SEND_STALL_TIMEOUT_MS = 60_000L; + private final ClipCascadeProperties clipCascadeProperties; private final LiveWebSocketService liveWebSockets; + private final ObjectProvider delivery; - public StompWebSocketConfig(ClipCascadeProperties clipCascadeProperties, LiveWebSocketService liveWebSockets) { + public StompWebSocketConfig(ClipCascadeProperties clipCascadeProperties, LiveWebSocketService liveWebSockets, ObjectProvider delivery) { this.clipCascadeProperties = clipCascadeProperties; this.liveWebSockets = liveWebSockets; + this.delivery = delivery; + } + + @Override + public void configureClientInboundChannel(@NonNull ChannelRegistration registration) { + registration.interceptors(new ExecutorChannelInterceptor() { + @Override + public Message preSend(@NonNull Message message, @NonNull MessageChannel channel) { + StompHeaderAccessor headers = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (headers == null) { + throw new AccessDeniedException("STOMP frame not allowed"); + } + if (headers.isHeartbeat()) { + return message; + } + + // Authorize the original client destination before Spring resolves user queues. + var command = headers.getCommand(); + boolean allowed = command != null && switch (command) { + case SEND -> headers.getUser() != null && ("/app/cliptext".equals(headers.getDestination()) + || "/app/clipbinary".equals(headers.getDestination())); + case SUBSCRIBE -> headers.getUser() != null + && "/user/queue/cliptext".equals(headers.getDestination()); + case CONNECT, STOMP, DISCONNECT, UNSUBSCRIBE, ACK, NACK -> headers.getDestination() == null; + default -> false; + }; + if (!allowed) { + throw new AccessDeniedException("STOMP frame not allowed"); + } + P2SDeliveryService.recordCapabilities(headers); + return message; + } + + @Override + public void afterMessageHandled(Message message, MessageChannel channel, + MessageHandler handler, Exception failure) { + if (failure != null) return; + var headers = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class); + if (headers == null) return; + if (headers.getCommand() == org.springframework.messaging.simp.stomp.StompCommand.SUBSCRIBE + && handler instanceof org.springframework.messaging.simp.user.UserDestinationMessageHandler) { + delivery.getObject().subscriptionReady(headers); + } else if (headers.getCommand() == org.springframework.messaging.simp.stomp.StompCommand.UNSUBSCRIBE + && handler instanceof org.springframework.messaging.simp.broker.AbstractBrokerMessageHandler) { + delivery.getObject().subscriptionRemoved(headers); + } + } + }); } @Override @@ -35,6 +103,8 @@ public void configureMessageBroker(@NonNull MessageBrokerRegistry config) { // Prefix for client-to-server communication (application-level messages). // Clients send messages to server endpoints starting with "/app". config.setApplicationDestinationPrefixes("/app"); + // Clipboard revisions must reach each recipient in the order published. + config.setPreservePublishOrder(true); // Prefix for user-specific destinations. // Used when sending messages to specific users via "/user/{sessionId}/...". @@ -74,7 +144,9 @@ public void configureMessageBroker(@NonNull MessageBrokerRegistry config) { @Override public void registerStompEndpoints(@NonNull StompEndpointRegistry registry) { // Clients will connect to this endpoint for WebSocket communication. + registry.setPreserveReceiveOrder(true); registry.addEndpoint("/clipsocket") + .addInterceptors(liveWebSockets.sessionHandshakeInterceptor()) .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); } @@ -111,6 +183,22 @@ public ServletServerContainerFactoryBean servletServerContainerFactoryBean() { @Override public void configureWebSocketTransport(@SuppressWarnings("null") WebSocketTransportRegistration registration) { registration.addDecoratorFactory(liveWebSockets::decorate); + registration.addDecoratorFactory(handler -> new WebSocketHandlerDecorator(handler) { + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + WebSocketSession transport = WebSocketSessionDecorator.unwrap(session); + if (!(transport instanceof NativeWebSocketSession nativeSession) + || !(nativeSession.getNativeSession() instanceof Session jakartaSession)) { + throw new IllegalStateException("P2S requires a native Jakarta WebSocket session"); + } + // Tomcat encodes outbound STOMP text in small buffer-sized writes. + // Each successful piece gets a fresh blocking-write deadline, so + // a slow transfer may continue without a whole-message deadline. + jakartaSession.getUserProperties().put( + Constants.BLOCKING_SEND_TIMEOUT_PROPERTY, SEND_STALL_TIMEOUT_MS); + super.afterConnectionEstablished(session); + } + }); int maxMessageSizeInBytes = (int) clipCascadeProperties.getOverheadMaxMessageSizeInBytes(); /* @@ -124,16 +212,17 @@ public void configureWebSocketTransport(@SuppressWarnings("null") WebSocketTrans /* * [SEND][EACH CONNECTION] - * Sets the timeout for sending messages. If an outgoing message exceeds this - * time while a new message arrives, the connection is terminated, and a warning - * is logged. + * Spring measures the whole send, not transport progress, and zero does NOT + * disable this check. Use its largest supported value (about 24.8 days); + * the native 60-second write-stall deadline above handles normal failures. + * This leaves Spring's per-session queue/buffer protection in place. */ - registration.setSendTimeLimit(200 * 1000); // default : 10 * 1000 + registration.setSendTimeLimit(Integer.MAX_VALUE); /* * [SEND][EACH CONNECTION] * Configures the buffer size (in bytes) used for sending queued messages. If - * the buffer is full and the timeout expires, the connection is terminated, and + * the buffer exceeds this limit, the connection is terminated, and * a warning is logged. * This occurs again when a new message arrives. */ diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java index f3e6aacba..4834f6852 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java @@ -1,6 +1,8 @@ package com.acme.clipcascade.constants; public class ServerConstants { + public static final String DEFAULT_ADMIN_PASSWORD = "admin123"; + public static final String DEFAULT_PASSWORD_PROMPT_SHOWN = "defaultPasswordPromptShown"; // App version public static final String APP_VERSION = "3.4.0"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/BinaryClipboardController.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/BinaryClipboardController.java new file mode 100644 index 000000000..f97c6207c --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/BinaryClipboardController.java @@ -0,0 +1,54 @@ +package com.acme.clipcascade.controller; + +import java.nio.ByteBuffer; +import java.security.Principal; +import java.util.Map; + +import com.acme.clipcascade.service.P2SDeliveryService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.messaging.handler.annotation.MessageMapping; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** Opaque encrypted routing. The relay never holds a clipboard encryption key. */ +@RestController +@ConditionalOnProperty(prefix = "app.p2p", name = "enabled", havingValue = "false") +public class BinaryClipboardController { + private final P2SDeliveryService delivery; + private final ObjectMapper mapper; + + public BinaryClipboardController(P2SDeliveryService delivery, ObjectMapper mapper) { + this.delivery = delivery; + this.mapper = mapper; + } + + @GetMapping("/clipbinary/recipients") + public org.springframework.http.ResponseEntity> recipients(Principal principal) { + return org.springframework.http.ResponseEntity.ok() + .cacheControl(org.springframework.http.CacheControl.noStore()) + .body(delivery.recipients(principal.getName())); + } + + @MessageMapping("/clipbinary") + public void send(Principal principal, byte[] data, SimpMessageHeaderAccessor source) throws Exception { + if (data.length < 8 || ByteBuffer.wrap(data).getInt() != 0x43434231) + throw new IllegalArgumentException("Invalid binary clipboard frame"); + int length = ByteBuffer.wrap(data, 4, 4).getInt(); + if (length < 0 || length > 65536 || length > data.length - 8) + throw new IllegalArgumentException("Invalid binary clipboard header"); + var header = mapper.readTree(data, 8, length); + if (!header.isObject() || !header.path("encrypted").isBoolean() + || !java.util.Set.of("text", "image", "files").contains(header.path("type").asText())) + throw new IllegalArgumentException("Invalid binary clipboard envelope"); + var metadata = header.path("metadata"); + if (metadata.path("isFragmented").asBoolean(false)) + throw new IllegalArgumentException("Fragmented relay frame"); + var size = metadata.path("contentSizeBytes"); + if (!size.isIntegralNumber() || !size.canConvertToLong() || size.longValue() < 0 + || size.longValue() > (1L << 53) - 1) + throw new IllegalArgumentException("Invalid binary clipboard size"); + delivery.sendBinary(principal.getName(), data, size.longValue(), source); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java index f4adea7b2..2c06fae74 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java @@ -10,8 +10,9 @@ import javax.imageio.ImageIO; import org.springframework.http.ResponseEntity; +import org.springframework.http.HttpStatus; import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -36,6 +37,7 @@ import com.acme.clipcascade.service.UserInfoService; import com.acme.clipcascade.service.UserService; import com.acme.clipcascade.service.OnlineUsersService; +import com.acme.clipcascade.service.P2SDeliveryService; import com.acme.clipcascade.service.WebSocketStatsService; import com.acme.clipcascade.utils.ResponseEntityUtil; import com.acme.clipcascade.utils.TimeUtility; @@ -54,6 +56,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.bind.annotation.PutMapping; @Controller @@ -64,7 +67,7 @@ public class ClipCascadeController { private final UserInfoService userInfoService; private final SessionService sessionService; private final FacadeUserService facadeUserService; - private final SimpMessagingTemplate simpMessagingTemplate; + private final P2SDeliveryService p2sDelivery; private final CaptchaService captchaService; private final BruteForceProtectionService bruteForceProtectionService; private final WebSocketStatsService webSocketStatsService; @@ -75,7 +78,7 @@ public ClipCascadeController( ClipCascadeProperties clipCascadeProperties, FacadeUserService facadeUserService, UserService userService, - @Nullable SimpMessagingTemplate simpMessagingTemplate, + @Nullable P2SDeliveryService p2sDelivery, CaptchaService captchaService, UserInfoService userInfoService, SessionService sessionService, @@ -87,7 +90,7 @@ public ClipCascadeController( this.clipCascadeProperties = clipCascadeProperties; this.facadeUserService = facadeUserService; this.userService = userService; - this.simpMessagingTemplate = simpMessagingTemplate; + this.p2sDelivery = p2sDelivery; this.captchaService = captchaService; this.userInfoService = userInfoService; this.sessionService = sessionService; @@ -153,15 +156,19 @@ public String logout() { @GetMapping("/admin/advance") public String advance( + @AuthenticationPrincipal UserPrincipal userPrincipal) { + return userPrincipal.isAdmin() ? "redirect:/#advanced" : "redirect:/"; + } + + @GetMapping("/admin/advance/fragment") + public String advanceFragment( @AuthenticationPrincipal UserPrincipal userPrincipal, Model model) { - - if (userPrincipal.isAdmin()) { - model.addAttribute("envVariables", clipCascadeProperties); - return "advance"; // advance.html - } else { - return "redirect:/"; // Redirect to the home page + if (!userPrincipal.isAdmin()) { + throw new ResponseStatusException(HttpStatus.FORBIDDEN); } + model.addAttribute("envVariables", clipCascadeProperties); + return "advance :: tools"; } /** @@ -174,7 +181,8 @@ public String advance( @MessageMapping("/cliptext") public void sendPrivateMessage( Principal principal, - ClipboardData clipboardData) { + ClipboardData clipboardData, + SimpMessageHeaderAccessor headers) { if (clipCascadeProperties.isP2pEnabled()) { return; @@ -191,19 +199,7 @@ public void sendPrivateMessage( (clipboardData.getType() == null) ? "text" : clipboardData.getType(), clipboardData.getMetadata()); - /** - * Send the message to the user's specific queue: - * - Destination: "/user/{username}/queue/cliptext" - * - Spring resolves the username using the Principal associated with the - * session. - * - Internally, Spring maps the WebSocket session ID to the username and routes - * messages accordingly. - * - By default username is the unique identifier for the user. - */ - simpMessagingTemplate.convertAndSendToUser( - userPrincipal.getUsername(), - "/queue/cliptext", - messageToSend); + p2sDelivery.send(userPrincipal.getUsername(), messageToSend, headers); } @GetMapping("/captcha") @@ -311,6 +307,23 @@ public ResponseEntity whoami(@AuthenticationPrincipal UserPrincipal userPrinc }); } + @GetMapping("/admin/default-password-status") + public ResponseEntity defaultPasswordStatus( + @AuthenticationPrincipal UserPrincipal userPrincipal, HttpSession session) { + if (!userPrincipal.isAdmin()) { + return ResponseEntity.status(403).build(); + } + + boolean usingDefaultPassword = userService.usesDefaultAdminPassword(userPrincipal.getUsername()); + boolean showPrompt = usingDefaultPassword + && session.getAttribute(ServerConstants.DEFAULT_PASSWORD_PROMPT_SHOWN) == null; + if (showPrompt) { + session.setAttribute(ServerConstants.DEFAULT_PASSWORD_PROMPT_SHOWN, true); + } + return ResponseEntity.ok().header("Cache-Control", "no-store") + .body(Map.of("usingDefaultPassword", usingDefaultPassword, "showPrompt", showPrompt)); + } + @GetMapping("/validate-session") public ResponseEntity validateSession() { return ResponseEntityUtil.executeWithResponse(() -> "OK"); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java index c60d2c3e2..faa68ac08 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java @@ -11,6 +11,7 @@ import com.acme.clipcascade.config.ClipCascadeProperties; import com.acme.clipcascade.constants.RoleConstants; +import com.acme.clipcascade.constants.ServerConstants; import com.acme.clipcascade.model.IpAttemptDetails; import com.acme.clipcascade.model.PageResponse; import com.acme.clipcascade.model.UserDetailsView; @@ -42,7 +43,7 @@ public void insertDefaultAdminUserIfEmpty() { if (userService.isTableEmpty()) { userService.doubleHashAndCreateUser( "admin", - "admin123", + ServerConstants.DEFAULT_ADMIN_PASSWORD, RoleConstants.ADMIN, true); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java index 75ba23222..8c7642fb1 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java @@ -1,22 +1,37 @@ package com.acme.clipcascade.service; +import static org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor.HTTP_SESSION_ID_ATTR_NAME; + import java.io.IOException; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; import org.springframework.stereotype.Service; import org.springframework.web.socket.CloseStatus; import org.springframework.web.socket.WebSocketHandler; import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.handler.WebSocketHandlerDecorator; +import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor; /** Tracks local transports so revoking a login also disconnects its live sockets. */ @Service public class LiveWebSocketService { private static final Logger logger = LoggerFactory.getLogger(LiveWebSocketService.class); private final Map sessions = new ConcurrentHashMap<>(); + private final JdbcIndexedSessionRepository persistentSessions; + + public LiveWebSocketService(ObjectProvider repositories) { + this.persistentSessions = repositories.getIfAvailable(); + } + + public HttpSessionHandshakeInterceptor sessionHandshakeInterceptor() { + return new HttpSessionHandshakeInterceptor(List.of()); + } public WebSocketHandler decorate(WebSocketHandler handler) { return new WebSocketHandlerDecorator(handler) { @@ -25,6 +40,13 @@ public void afterConnectionEstablished(WebSocketSession session) throws Exceptio sessions.put(session.getId(), session); try { super.afterConnectionEstablished(session); + // Catch upgrades that passed HTTP authentication just before JDBC logout. + if (persistentSessions != null && session.isOpen()) { + String loginId = (String) session.getAttributes().get(HTTP_SESSION_ID_ATTR_NAME); + if (loginId == null || persistentSessions.findById(loginId) == null) { + disconnect(session); + } + } } catch (Exception ex) { sessions.remove(session.getId(), session); throw ex; @@ -50,6 +72,12 @@ public void disconnectUser(String username) { .forEach(this::disconnect); } + public void disconnectLogin(String loginId) { + sessions.values().stream() + .filter(session -> loginId.equals(session.getAttributes().get(HTTP_SESSION_ID_ATTR_NAME))) + .forEach(this::disconnect); + } + public void disconnectAll() { sessions.values().forEach(this::disconnect); } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/P2SDeliveryService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/P2SDeliveryService.java new file mode 100644 index 000000000..140502d7a --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/P2SDeliveryService.java @@ -0,0 +1,253 @@ +package com.acme.clipcascade.service; + +import java.util.Map; +import java.util.List; +import java.util.Set; +import java.util.Arrays; +import java.util.stream.Collectors; +import org.springframework.util.MimeTypeUtils; +import java.util.HashSet; +import java.util.UUID; +import org.springframework.scheduling.annotation.Scheduled; +import java.util.concurrent.ConcurrentHashMap; +import java.math.BigInteger; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.messaging.simp.SimpMessageHeaderAccessor; +import org.springframework.messaging.simp.SimpMessageType; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.messaging.simp.stomp.StompCommand; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.messaging.simp.user.SimpUser; +import org.springframework.messaging.simp.user.SimpUserRegistry; +import org.springframework.stereotype.Service; +import org.springframework.context.event.EventListener; +import org.springframework.web.socket.messaging.SessionConnectEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import com.acme.clipcascade.model.ClipboardData; + +@Service +@ConditionalOnProperty(prefix = "app.p2p", name = "enabled", havingValue = "false", matchIfMissing = false) +public class P2SDeliveryService { + public static final String UPDATES_HEADER = "x-clipcascade-recipient-updates"; + private static final String CLOSED_ATTRIBUTE = P2SDeliveryService.class.getName() + ".closed"; + private static final String UPDATES_ATTRIBUTE = P2SDeliveryService.class.getName() + ".updates"; + private record Subscriber(String username, boolean binary, boolean updates, Set subscriptions) {} + private final Map subscribers = new java.util.HashMap<>(); + private final String updatesEpoch = UUID.randomUUID().toString(); + private long updatesRevision; + public static final String BINARY_HEADER = "x-clipcascade-binary"; + public static final String RECIPIENTS_HEADER = "x-clipcascade-recipients"; + private static final String BINARY_ATTRIBUTE = P2SDeliveryService.class.getName() + ".binary"; + private final Set binarySessions = ConcurrentHashMap.newKeySet(); + public static final String NO_SELF_ECHO_HEADER = "x-clipcascade-no-self-echo"; + private static final String NO_SELF_ECHO_ATTRIBUTE = P2SDeliveryService.class.getName() + ".noSelfEcho"; + public static final String RECEIVE_LIMIT_HEADER = "x-clipcascade-receive-limit"; + private static final String RECEIVE_LIMIT_ATTRIBUTE = P2SDeliveryService.class.getName() + ".receiveLimit"; + private static final long MAX_SIZE = (1L << 53) - 1; + private static final String DESTINATION = "/queue/cliptext"; + + private final SimpMessagingTemplate messaging; + private final SimpUserRegistry users; + private final Map receiveLimits = new ConcurrentHashMap<>(); + + public P2SDeliveryService(SimpMessagingTemplate messaging, SimpUserRegistry users) { + this.messaging = messaging; + this.users = users; + } + + public static void recordCapabilities(StompHeaderAccessor headers) { + if (headers.getCommand() == StompCommand.CONNECT || headers.getCommand() == StompCommand.STOMP) { + Map attributes = headers.getSessionAttributes(); + if (attributes != null) { + attributes.put(UPDATES_ATTRIBUTE, "1".equals(headers.getFirstNativeHeader(UPDATES_HEADER))); + attributes.put(BINARY_ATTRIBUTE, "ccb1".equals(headers.getFirstNativeHeader(BINARY_HEADER))); + attributes.put(NO_SELF_ECHO_ATTRIBUTE, + "true".equals(headers.getFirstNativeHeader(NO_SELF_ECHO_HEADER))); + attributes.remove(RECEIVE_LIMIT_ATTRIBUTE); + String limit = headers.getFirstNativeHeader(RECEIVE_LIMIT_HEADER); + if ("unlimited".equals(limit)) { + attributes.put(RECEIVE_LIMIT_ATTRIBUTE, -1L); + } else if (limit != null && limit.matches("[0-9]{1,16}")) { + try { + long value = Long.parseLong(limit); + if (value <= MAX_SIZE) attributes.put(RECEIVE_LIMIT_ATTRIBUTE, value); + } catch (NumberFormatException ignored) {} + } + } + } + } + + @EventListener + public synchronized void onConnect(SessionConnectEvent event) { + var headers = SimpMessageHeaderAccessor.wrap(event.getMessage()); + Map attributes = headers.getSessionAttributes(); + if (headers.getSessionId() != null && attributes != null + && !Boolean.TRUE.equals(attributes.get(CLOSED_ATTRIBUTE)) + && attributes.get(RECEIVE_LIMIT_ATTRIBUTE) instanceof Long limit) { + receiveLimits.put(headers.getSessionId(), limit); + } + } + + @EventListener + public synchronized void onDisconnect(SessionDisconnectEvent event) { + var attributes = SimpMessageHeaderAccessor.wrap(event.getMessage()).getSessionAttributes(); + // The same attribute map is retained by inbound tasks queued before socket shutdown. + if (attributes != null) attributes.put(CLOSED_ATTRIBUTE, true); + receiveLimits.remove(event.getSessionId()); + binarySessions.remove(event.getSessionId()); + Subscriber removed = subscribers.remove(event.getSessionId()); + if (removed != null) pushRecipients(removed.username()); + } + + /** Called after UserDestinationMessageHandler has installed the translated subscription. + * SessionSubscribeEvent alone can precede broker registration and lose the initial update. + */ + public synchronized void subscriptionReady(SimpMessageHeaderAccessor headers) { + String session = headers.getSessionId(); + String subscription = headers.getSubscriptionId(); + if (!"/user/queue/cliptext".equals(headers.getDestination()) || session == null + || subscription == null || headers.getUser() == null) return; + Map attributes = headers.getSessionAttributes(); + if (attributes != null && Boolean.TRUE.equals(attributes.get(CLOSED_ATTRIBUTE))) return; + boolean binary = attributes != null && Boolean.TRUE.equals(attributes.get(BINARY_ATTRIBUTE)); + boolean updates = attributes != null && Boolean.TRUE.equals(attributes.get(UPDATES_ATTRIBUTE)); + if (binary) binarySessions.add(session); + Subscriber state = subscribers.get(session); + if (state == null) { + state = new Subscriber(headers.getUser().getName(), binary, updates, new HashSet<>()); + subscribers.put(session, state); + } + if (state.subscriptions().add(subscription)) pushRecipients(state.username()); + } + + public synchronized void subscriptionRemoved(SimpMessageHeaderAccessor headers) { + Subscriber state = subscribers.get(headers.getSessionId()); + if (state == null || !state.subscriptions().remove(headers.getSubscriptionId())) return; + if (!state.subscriptions().isEmpty()) return; + subscribers.remove(headers.getSessionId()); + binarySessions.remove(headers.getSessionId()); + pushRecipients(state.username()); + } + + /** Full snapshots also repair dropped updates; clipboard traffic is never required. */ + @Scheduled(fixedDelay = 20_000, initialDelay = 20_000) + public synchronized void refreshRecipientUpdates() { + subscribers.values().stream().filter(Subscriber::updates).map(Subscriber::username) + .collect(Collectors.toSet()).forEach(this::pushRecipients); + } + + private void pushRecipients(String username) { + List> peers = subscribers.entrySet().stream() + .filter(entry -> entry.getValue().username().equals(username)) + .limit(257) // Oversized snapshots invalidate the cache and use legacy fallback. + .map(entry -> Map.of("id", entry.getKey(), "binary", entry.getValue().binary())) + .toList(); + Map snapshot = Map.of("_cc_control", "relay-recipients", "version", 1, + "epoch", updatesEpoch, "revision", ++updatesRevision, "format", "ccb1", "recipients", peers); + for (var entry : subscribers.entrySet()) { + Subscriber recipient = entry.getValue(); + // Sending control data requires explicit opt-in, even for binary-capable clients. + if (!recipient.updates() || !recipient.username().equals(username)) continue; + var target = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); + target.setSessionId(entry.getKey()); + target.setLeaveMutable(true); + try { + messaging.convertAndSendToUser(entry.getKey(), DESTINATION, snapshot, target.getMessageHeaders()); + } catch (org.springframework.messaging.MessagingException failure) { + org.slf4j.LoggerFactory.getLogger(P2SDeliveryService.class) + .debug("Could not push recipient update to disconnected session", failure); + } + } + } + + public Map recipients(String username) { + SimpUser user = users.getUser(username); + List> peers = user == null ? List.of() : user.getSessions().stream() + .map(session -> Map.of("id", session.getId(), + "binary", binarySessions.contains(session.getId()))) + .toList(); + return Map.of("format", "ccb1", "recipients", peers); + } + + private Set requestedRecipients(SimpMessageHeaderAccessor source) { + String value = org.springframework.messaging.support.NativeMessageHeaderAccessor + .getFirstNativeHeader(RECIPIENTS_HEADER, source.getMessageHeaders()); + if (value == null) return null; + if (source.getSessionAttributes() == null + || !Boolean.TRUE.equals(source.getSessionAttributes().get(BINARY_ATTRIBUTE)) + || value.length() > 32768) throw new IllegalArgumentException("Invalid binary recipients"); + var ids = value.split(",", -1); + if (ids.length > 256 || Arrays.stream(ids).anyMatch(id -> !id.matches("[A-Za-z0-9_-]{1,128}"))) + throw new IllegalArgumentException("Invalid binary recipients"); + return Arrays.stream(ids).collect(Collectors.toSet()); + } + + public void sendBinary(String username, byte[] data, Long size, SimpMessageHeaderAccessor source) { + Set requested = requestedRecipients(source); + if (requested == null) throw new IllegalArgumentException("Binary recipients are required"); + SimpUser user = users.getUser(username); + if (user == null) return; + for (var session : user.getSessions()) { + String recipient = session.getId(); + if (recipient.equals(source.getSessionId()) || !requested.contains(recipient) + || !binarySessions.contains(recipient)) continue; + Long limit = receiveLimits.get(recipient); + if (size != null && limit != null && limit >= 0 && size > limit) continue; + var target = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); + target.setSessionId(recipient); + target.setContentType(MimeTypeUtils.parseMimeType("application/octet-stream")); + target.setLeaveMutable(true); + messaging.convertAndSendToUser(recipient, DESTINATION, data, target.getMessageHeaders()); + } + } + + private Long contentSize(ClipboardData data) { + Object value = data.getMetadata() == null ? null : data.getMetadata().get("contentSizeBytes"); + if (!(value instanceof Byte || value instanceof Short || value instanceof Integer + || value instanceof Long || value instanceof BigInteger)) return null; + try { + long size = new BigInteger(value.toString()).longValueExact(); + return size >= 0 && size <= MAX_SIZE ? size : null; + } catch (ArithmeticException ignored) { + return null; + } + } + + public void send(String username, ClipboardData data, SimpMessageHeaderAccessor source) { + Map attributes = source.getSessionAttributes(); + String sender = source.getSessionId(); + boolean noEcho = sender != null && attributes != null + && Boolean.TRUE.equals(attributes.get(NO_SELF_ECHO_ATTRIBUTE)); + Set requested = requestedRecipients(source); + Long size = contentSize(data); + SimpUser user = users.getUser(username); + boolean needsFiltering = size != null && user != null && user.getSessions().stream().anyMatch(session -> { + Long limit = receiveLimits.get(session.getId()); + return !session.getId().equals(sender) && limit != null && limit >= 0 && size > limit; + }); + if (requested == null && !noEcho && !needsFiltering) { + // Preserve the exact legacy broadcast when no recipient needs + // filtering. Old mobile clients require their own echo to unlock. + messaging.convertAndSendToUser(username, DESTINATION, data); + return; + } + if (user == null) return; + for (var session : user.getSessions()) { + String recipient = session.getId(); + if (noEcho && recipient.equals(sender)) continue; + if (requested != null && !requested.contains(recipient)) continue; + Long limit = receiveLimits.get(recipient); + // A legacy sender must still receive its unlocking echo. Limits + // affect other recipients, never the acknowledgment of that send. + if (!recipient.equals(sender) && size != null && limit != null && limit >= 0 && size > limit) continue; + SimpMessageHeaderAccessor target = SimpMessageHeaderAccessor.create(SimpMessageType.MESSAGE); + target.setSessionId(recipient); + target.setLeaveMutable(true); + // Target the session itself. If it disconnects during routing, + // Spring must not fall back to a user-wide broadcast. + messaging.convertAndSendToUser(recipient, DESTINATION, data, target.getMessageHeaders()); + } + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java index 4d83c5429..7b5be418b 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import com.acme.clipcascade.constants.RoleConstants; +import com.acme.clipcascade.constants.ServerConstants; import com.acme.clipcascade.model.PageResponse; import com.acme.clipcascade.model.Users; import com.acme.clipcascade.repo.UserRepo; @@ -24,6 +25,7 @@ public class UserService { private final UserRepo userRepo; private final BCryptPasswordEncoder bCryptPasswordEncoder; private final MessageDigest shaPasswordEncoder; + private final String defaultAdminPasswordDigest; public UserService(UserRepo userRepo, BCryptPasswordEncoder bCryptPasswordEncoder, @@ -32,6 +34,15 @@ public UserService(UserRepo userRepo, this.userRepo = userRepo; this.bCryptPasswordEncoder = bCryptPasswordEncoder; this.shaPasswordEncoder = shaPasswordEncoder; + this.defaultAdminPasswordDigest = HashingUtility.preHashPassword( + ServerConstants.DEFAULT_ADMIN_PASSWORD, shaPasswordEncoder); + } + + public boolean usesDefaultAdminPassword(String username) { + return userRepo.findById(username) + .filter(user -> RoleConstants.ADMIN.equals(user.getRole())) + .map(user -> bCryptPasswordEncoder.matches(defaultAdminPasswordDigest, user.getPassword())) + .orElse(false); } public void doubleHashAndCreateUser( diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/HashingUtility.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/HashingUtility.java index 4fa86b8f9..027d4d0e2 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/HashingUtility.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/HashingUtility.java @@ -19,10 +19,11 @@ public static String hashPassword(String input, PasswordEncoder encoder) { return encoder.encode(input); } - public static String doubleHash(String input, MessageDigest sha, PasswordEncoder encoder) { - String layer1 = convertBytesToLowercaseHex( - sha.digest(input.getBytes(StandardCharsets.UTF_8))); // Layer 1 hash + public static String preHashPassword(String input, MessageDigest sha) { + return convertBytesToLowercaseHex(sha.digest(input.getBytes(StandardCharsets.UTF_8))); + } - return hashPassword(layer1, encoder); // Layer 2 hash + public static String doubleHash(String input, MessageDigest sha, PasswordEncoder encoder) { + return hashPassword(preHashPassword(input, sha), encoder); } } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/responsive.css b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/responsive.css index 83fa48248..8a5887eb4 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/responsive.css +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/responsive.css @@ -49,7 +49,6 @@ right: 0; } .dashboard-page .modal-content h3 { padding-right: 28px; } -.advance-page .section-container, .dashboard-page .alert { overflow-wrap: anywhere; } .auth-page .captcha-image-container { min-width: 0; } .auth-page #captchaImage { max-width: 100%; height: auto; } @@ -76,9 +75,6 @@ } @media (max-width: 991px) { - .advance-page { padding: 16px 0; } - .advance-page > .container { max-width: 100%; } - .advance-page .unlock-user-btn { margin-top: 0; } .dashboard-page #user-section .form-inline .form-group, .dashboard-page #ws_text { display: block; width: 100%; } .dashboard-page #send { margin-top: 8px; } @@ -106,13 +102,10 @@ .dashboard-page .admin-toolbar .admin-search-input { width: 100%; max-width: none; } .dashboard-page .footer { justify-content: center; } .dashboard-page .footer #server-version { flex-basis: 100%; margin: 0; text-align: center; } - .advance-page .section-container { padding: 16px; } - .advance-page .table-toolbar .search-input { max-width: none; flex: 1 1 100%; } - .advance-page h1 { font-size: 1.75rem; } - .advance-page h2 { font-size: 1.4rem; } - .advance-page .theme-header { padding: 0 0 54px; } - .advance-page .theme-header .theme-toggle { top: auto; bottom: 0; } - .advance-page .table-toolbar > .btn { white-space: normal; } + #advanced-view .section-container { padding: 16px; } + #advanced-view .table-toolbar .search-input { max-width: none; flex: 1 1 100%; } + #advanced-view .section-container h2 { font-size: 20px; } + .advance-unlock-row > :first-child { width: 100%; } /* 16px fields avoid focus zoom on iOS; 44px controls are easier to tap. */ body .form-control, body .form-select { font-size: 16px; min-height: 44px; } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css index 913f98f13..6bffbb13c 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css @@ -231,3 +231,79 @@ label small { .col-resizer:hover { background: rgba(0, 123, 255, 0.25); } + +.advanced-header, +#advanced-view .table-toolbar, +#advanced-view .table-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} +.advanced-header, +#advanced-view .section-container { + margin-bottom: 20px; +} +.advanced-header h2, +#advanced-view .section-container h2 { + margin-top: 0; +} +.advanced-header h2 { + margin-bottom: 0; +} +#advanced-back { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; +} +#advanced-back:focus-visible { + outline: 2px solid var(--cc-link); + outline-offset: 2px; +} +#advanced-status:empty { + display: none; +} +#advanced-view .section-container { + overflow-wrap: anywhere; +} +#advanced-view .table thead th { + position: sticky; + top: 0; + z-index: 1; + cursor: pointer; + user-select: none; +} +#advanced-view .sort-indicator { + font-size: 0.75em; + margin-left: 4px; +} +#advanced-view .sort-asc .sort-indicator::after { + content: "▲"; +} +#advanced-view .sort-desc .sort-indicator::after { + content: "▼"; +} +#advanced-view .table-toolbar .search-input { + max-width: 280px; +} +#advanced-view .table-pager { + margin-top: 8px; +} +#advanced-view .table-pager select { + width: auto; +} +.advance-unlock-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + row-gap: 8px; +} +.advance-output-button { + margin-bottom: 8px; +} +#websocket-stats-output, +#server-time-output { + min-height: 20px; + padding: 8px; +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css index bc86b24c4..375197222 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css @@ -53,6 +53,17 @@ body .section-container { padding: 20px; } body a { color: var(--cc-link); } +#default-password-dialog { + width: min(440px, calc(100% - 32px)); + padding: 24px; + color: var(--cc-text); + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: 12px; +} +#default-password-dialog h3 { margin-top: 0; } +#default-password-dialog .btn { margin-top: 6px; } +#default-password-dialog::backdrop { background: rgb(0 0 0 / 50%); } body a:hover { color: var(--cc-link); } body hr { border-color: var(--cc-border); opacity: 1; } body label small, diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/LICENSE-bootstrap.txt b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/LICENSE-bootstrap.txt new file mode 100644 index 000000000..7a300022c --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/LICENSE-bootstrap.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2011-2016 Twitter, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.eot b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.eot new file mode 100644 index 000000000..b93a4953f Binary files /dev/null and b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.eot differ diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.svg b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.svg new file mode 100644 index 000000000..94fb5490a --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.svg @@ -0,0 +1,288 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.ttf b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.ttf new file mode 100644 index 000000000..1413fc609 Binary files /dev/null and b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.ttf differ diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff new file mode 100644 index 000000000..9e612858f Binary files /dev/null and b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff differ diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff2 b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff2 new file mode 100644 index 000000000..64539b54c Binary files /dev/null and b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/fonts/glyphicons-halflings-regular.woff2 differ diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js new file mode 100644 index 000000000..1c4f99d06 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/advance.js @@ -0,0 +1,576 @@ +function initAdvancedNavigation() { + const hash = "#advanced"; + const link = document.getElementById("advance-link"); + const back = document.getElementById("advanced-back"); + const dashboard = document.getElementById("dashboard-view"); + const advanced = document.getElementById("advanced-view"); + const tools = document.getElementById("advanced-tools"); + const status = document.getElementById("advanced-status"); + const menu = document.getElementById("navigation-toggle"); + let loading = false; + let loaded = false; + + async function loadTools() { + if (loading || loaded) return; + loading = true; + status.textContent = "Loading Advanced tools…"; + try { + const response = await fetch("/admin/advance/fragment", { redirect: "error" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + tools.innerHTML = await response.text(); + initializeAdvancedTools(tools); + loaded = true; + status.textContent = ""; + } catch (error) { + console.error("Failed to load Advanced tools:", error); + tools.replaceChildren(); + status.textContent = "Could not load Advanced tools. Select Advanced to try again."; + } finally { + loading = false; + } + } + + function renderView() { + if (location.hash === hash) { + dashboard.hidden = true; + advanced.hidden = false; + menu.setAttribute("aria-expanded", "false"); + document.getElementById("account-navigation").classList.remove("is-open"); + document.getElementById("advanced-title").focus({ preventScroll: true }); + window.scrollTo(0, 0); + loadTools(); + } else if (!advanced.hidden) { + advanced.hidden = true; + dashboard.hidden = false; + const focusTarget = link.getClientRects().length ? link : menu; + focusTarget.focus({ preventScroll: true }); + } + } + + link.addEventListener("click", (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + if (location.hash !== hash) { + history.pushState({ fromDashboard: true }, "", hash); + } + renderView(); + }); + + back.addEventListener("click", (event) => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + if (history.state?.fromDashboard) { + history.back(); + } else { + history.replaceState(null, "", location.pathname + location.search); + renderView(); + } + }); + + window.addEventListener("hashchange", renderView); + renderView(); +} + +function initializeAdvancedTools(section) { + /** + * Constants for the API endpoints + */ + const ENDPOINTS = { + 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_PAGE: "/admin/bfa-snapshot/page", + BFA_SNAPSHOT_FILE: "/admin/bfa-snapshot-file", + UNLOCK_USER: "/admin/unlock-user", + SERVER_TIME: "/admin/server-time", + }; + + /** + * Generic fetch helper with basic error checking. + */ + async function fetchData(url, method = "GET", body = null, headers = {}) { + try { + const options = { method, headers: { ...headers } }; + if (body) { + options.headers["Content-Type"] = "application/json"; + options.body = JSON.stringify(body); + } + const response = await fetch(url, options); + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + return response; + } catch (error) { + console.error("Fetch error:", error); + throw error; + } + } + + /** + * Format epoch (seconds) to a human-readable, locale-specific string. + */ + function formatEpochToLocal(epoch) { + if (epoch == null || epoch === 0) return ""; + const date = new Date(epoch * 1000); + const year = date.getFullYear(); + const month = date.toLocaleString(undefined, { month: "short" }); + const day = String(date.getDate()).padStart(2, "0"); + let hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, "0"); + const seconds = String(date.getSeconds()).padStart(2, "0"); + const ampm = hours >= 12 ? "PM" : "AM"; + hours = hours % 12 || 12; + const hour12 = String(hours).padStart(2, "0"); + const tzMatch = date.toTimeString().match(/\(([A-Za-z\s]+)\)$/); + const tz = tzMatch + ? tzMatch[1] + .split(" ") + .map((w) => w[0]) + .join("") + : ""; + return `${year} ${month} ${day} ${hour12}:${minutes}:${seconds} ${ampm} ${tz}`; + } + + /** + * Make table columns drag-resizable. Switches the table to fixed layout, + * locks each at its current width, and adds a right-edge drag handle. + */ + function enableColumnResize(tableEl) { + if (!tableEl || tableEl.dataset.resizableInit === "1") return; + tableEl.dataset.resizableInit = "1"; + tableEl.classList.add("table-resizable"); + + tableEl.querySelectorAll("thead th").forEach((th) => { + if (!th.style.width) th.style.width = th.offsetWidth + "px"; + + const handle = document.createElement("div"); + handle.className = "col-resizer"; + th.appendChild(handle); + + let startX = 0; + let startW = 0; + const onMove = (e) => { + const dx = e.clientX - startX; + th.style.width = Math.max(40, startW + dx) + "px"; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + }; + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + startX = e.clientX; + startW = th.offsetWidth; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + handle.addEventListener("click", (e) => e.stopPropagation()); + }); + } + + /** + * Calls a paged admin endpoint and returns the PageResponse JSON. + * Backend treats size <= 0 as "all rows" and accepts 0-based page index. + */ + async function fetchPage(url, params) { + const qs = new URLSearchParams(); + if (params.page != null) qs.set("page", String(params.page)); + if (params.size != null) qs.set("size", String(params.size)); + if (params.sort) qs.set("sort", params.sort); + if (params.direction) qs.set("direction", params.direction); + if (params.search) qs.set("search", params.search); + const res = await fetchData(`${url}?${qs.toString()}`); + return await res.json(); + } + + /** + * Generic data-table controller backed by a server endpoint. + * The endpoint owns sort/filter/paginate; this just renders the page + * it returns and forwards user actions (sort click, search, page nav, + * page-size change) back as fresh requests. + */ + function createDataTable(opts) { + const tableEl = document.querySelector( + `table[data-table-id="${opts.tableId}"]` + ); + const tbody = document.getElementById(opts.tbodyId); + const searchEl = document.getElementById(opts.searchId); + const pagerEl = document.getElementById(opts.pagerId); + + const state = { + content: [], + totalElements: 0, + totalPages: 1, + page: 0, // 0-based to match backend + pageSize: 10, // -1 means "All" + sortKey: opts.defaultSortKey || null, + sortDir: opts.defaultSortDir || "asc", + search: "", + }; + + async function reload() { + if (!opts.fetcher) return; + try { + const data = await opts.fetcher({ + page: state.page, + size: state.pageSize, + sort: state.sortKey, + direction: state.sortDir, + search: state.search, + }); + state.content = Array.isArray(data.content) ? data.content : []; + state.totalElements = + typeof data.totalElements === "number" + ? data.totalElements + : state.content.length; + state.totalPages = + typeof data.totalPages === "number" && data.totalPages > 0 + ? data.totalPages + : 1; + if (typeof data.page === "number") state.page = data.page; + render(); + } catch (err) { + console.error(err); + if (opts.onError) opts.onError(err); + } + } + + function render() { + tbody.innerHTML = ""; + state.content.forEach((row) => { + const tr = document.createElement("tr"); + opts.columns.forEach((col) => { + const td = document.createElement("td"); + let v = row[col.key]; + if (col.type === "date") { + v = v ? formatEpochToLocal(v) : ""; + } else if (col.type === "bool") { + v = v === true ? "Enabled" : v === false ? "Disabled" : ""; + } else if (v == null) { + v = ""; + } + td.textContent = v; + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + + // Header sort indicators + tableEl.querySelectorAll("thead th[data-key]").forEach((h) => { + h.classList.remove("sort-asc", "sort-desc"); + if (h.dataset.key === state.sortKey) { + h.classList.add( + state.sortDir === "asc" ? "sort-asc" : "sort-desc" + ); + } + }); + + // Pager + pagerEl.innerHTML = ""; + const total = state.totalElements; + const usingAll = state.pageSize <= 0; + const lo = + total === 0 + ? 0 + : usingAll + ? 1 + : state.page * state.pageSize + 1; + const hi = usingAll + ? total + : Math.min((state.page + 1) * state.pageSize, total); + + const prev = document.createElement("button"); + prev.className = "btn btn-default btn-sm"; + prev.textContent = "Prev"; + prev.disabled = usingAll || state.page <= 0; + prev.onclick = () => { + state.page = Math.max(0, state.page - 1); + reload(); + }; + + const next = document.createElement("button"); + next.className = "btn btn-default btn-sm"; + next.textContent = "Next"; + next.disabled = usingAll || state.page >= state.totalPages - 1; + next.onclick = () => { + state.page = Math.min(state.totalPages - 1, state.page + 1); + reload(); + }; + + const pageLabel = document.createElement("span"); + pageLabel.className = "page-info"; + pageLabel.textContent = `Page ${state.page + 1} / ${state.totalPages}`; + + const sizeSel = document.createElement("select"); + sizeSel.className = "form-control input-sm"; + sizeSel.setAttribute("aria-label", "Rows per page"); + const sizeOptions = [ + { value: 10, label: "10 / page" }, + { value: 25, label: "25 / page" }, + { value: 50, label: "50 / page" }, + { value: 100, label: "100 / page" }, + { value: -1, label: "All" }, + ]; + sizeOptions.forEach(({ value, label }) => { + const o = document.createElement("option"); + o.value = String(value); + o.textContent = label; + if (value === state.pageSize) o.selected = true; + sizeSel.appendChild(o); + }); + sizeSel.onchange = (e) => { + state.pageSize = Number(e.target.value); + state.page = 0; + reload(); + }; + + const info = document.createElement("span"); + info.className = "page-info"; + info.textContent = `${lo}-${hi} of ${total}`; + + pagerEl.appendChild(prev); + pagerEl.appendChild(pageLabel); + pagerEl.appendChild(next); + pagerEl.appendChild(sizeSel); + pagerEl.appendChild(info); + } + + enableColumnResize(tableEl); + + // Header click → toggle sort + reload from backend + tableEl.querySelectorAll("thead th[data-key]").forEach((h) => { + if (h.dataset.sortable === "false") return; + h.addEventListener("click", () => { + const key = h.dataset.key; + if (state.sortKey === key) { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = key; + state.sortDir = "asc"; + } + state.page = 0; + reload(); + }); + }); + + // Search input (debounced) → reload from backend + if (searchEl) { + let timer = null; + searchEl.addEventListener("input", (e) => { + state.search = e.target.value; + state.page = 0; + clearTimeout(timer); + timer = setTimeout(reload, 250); + }); + } + + // Render empty pager so the UI shows the controls before the first fetch + render(); + + return { + reload, + }; + } + + // --- Table instances (server-driven sort/filter/paginate) --- + const userDetailsTable = createDataTable({ + tableId: "userDetails", + tbodyId: "user-details-tbody", + searchId: "user-details-search", + pagerId: "user-details-pager", + columns: [ + { key: "username" }, + { key: "enabled", type: "bool" }, + { key: "markedForDeletion" }, + { key: "firstSignup", type: "date" }, + { key: "lastLogin", type: "date" }, + { key: "firstSignupIp" }, + { key: "lastLoginIp" }, + { key: "failedLoginAttempts", type: "number" }, + { key: "lockoutTime" }, + { key: "passwordChangedAt", type: "date" }, + { key: "email" }, + { key: "otp" }, + { key: "phone" }, + { key: "firstName" }, + { key: "lastName" }, + { key: "notes" }, + ], + defaultSortKey: "lastLogin", + defaultSortDir: "desc", + fetcher: (params) => fetchPage(ENDPOINTS.USER_DETAILS_PAGE, params), + onError: () => alert("Error fetching user details."), + }); + + const onlineUsersTable = createDataTable({ + tableId: "onlineUsers", + tbodyId: "online-users-tbody", + searchId: "online-users-search", + pagerId: "online-users-pager", + columns: [{ key: "username" }, { key: "connections", type: "number" }], + defaultSortKey: "connections", + defaultSortDir: "desc", + fetcher: (params) => fetchPage(ENDPOINTS.ONLINE_USERS_PAGE, params), + onError: () => alert("Error fetching online users."), + }); + + const bfaSnapshotTable = createDataTable({ + tableId: "bfaSnapshot", + tbodyId: "bfa-snapshot-tbody", + searchId: "bfa-snapshot-search", + pagerId: "bfa-snapshot-pager", + columns: [ + { key: "username" }, + { key: "sourceIp" }, + { key: "attempts", type: "number" }, + { key: "lockCount", type: "number" }, + { key: "lockTimeout", type: "date" }, + { key: "uniqueIpCount", type: "number" }, + { key: "userLockTimeout", type: "date" }, + ], + defaultSortKey: "username", + defaultSortDir: "asc", + fetcher: (params) => fetchPage(ENDPOINTS.BFA_SNAPSHOT_PAGE, params), + onError: () => alert("Error fetching BFA tracker data."), + }); + + /** + * 1) User details — reload the current backend page + */ + function getUserDetails() { + return userDetailsTable.reload(); + } + + /** + * 2) WebSocket stats + */ + async function getWebsocketStats() { + try { + const response = await fetchData(ENDPOINTS.WEBSOCKET_STATS); + const textData = await response.text(); + document.getElementById("websocket-stats-output").textContent = + textData; + } catch (error) { + console.error("Error fetching WebSocket stats:", error); + alert("Error fetching WebSocket stats."); + } + } + + /** + * 3) Online users — refresh both the table (paged) and the + * "N user(s), M connection(s)" summary (computed over the full + * map via the legacy unpaged endpoint, since the summary needs + * the total connection count, not just the current page). + */ + async function getOnlineUsers() { + try { + const response = await fetchData(ENDPOINTS.ONLINE_USERS); + const data = await response.json(); + const totalUsers = Object.keys(data || {}).length; + const totalConnections = Object.values(data || {}).reduce( + (sum, n) => sum + (Number(n) || 0), + 0 + ); + document.getElementById( + "online-users-summary" + ).textContent = `${totalUsers} user(s), ${totalConnections} connection(s)`; + } catch (err) { + console.error(err); + } + return onlineUsersTable.reload(); + } + + /** + * 4) BFA tracker — reload the current backend page (flattening is + * now done server-side). + */ + function getBfaTracker() { + return bfaSnapshotTable.reload(); + } + + /** + * Download BFA snapshot + */ + async function downloadBfaSnapshot() { + try { + const response = await fetchData(ENDPOINTS.BFA_SNAPSHOT_FILE); + const blob = await response.blob(); + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const fileName = `brute_force_tracker_snapshot_${timestamp}.json`; + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); + alert("Snapshot downloaded successfully!"); + } catch (error) { + console.error("Error downloading snapshot:", error); + alert("Error downloading snapshot."); + } + } + + /** + * Unlock user + */ + async function unlockUser(event) { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const username = formData.get("username").trim(); + const csrfToken = formData.get("_csrf"); + if (!username) { + alert("Please enter a username to unlock."); + return; + } + try { + await fetchData( + ENDPOINTS.UNLOCK_USER, + "PUT", + { username }, + { "X-CSRF-TOKEN": csrfToken } + ); + alert("User unlocked successfully!"); + } catch (error) { + console.error("Error unlocking user:", error); + alert("Error unlocking user."); + } + } + + /** + * Server time + */ + async function showServerTime() { + try { + const response = await fetchData(ENDPOINTS.SERVER_TIME); + const serverTime = await response.text(); + document.getElementById("server-time-output").textContent = + serverTime; + } catch (error) { + console.error("Error fetching server time:", error); + alert("Error fetching server time."); + } + } + + const actions = { + getUserDetails, + getWebsocketStats, + getOnlineUsers, + getBfaTracker, + downloadBfaSnapshot, + showServerTime, + }; + section.querySelectorAll("button[data-action]").forEach((button) => { + button.addEventListener("click", actions[button.dataset.action]); + }); + section.querySelector("#unlock-user-form").addEventListener("submit", unlockUser); + + getUserDetails(); + getWebsocketStats(); + getOnlineUsers(); + getBfaTracker(); + showServerTime(); +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/binary-protocol.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/binary-protocol.js new file mode 100644 index 000000000..573745b70 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/binary-protocol.js @@ -0,0 +1,135 @@ +/* CCB1 codec. Keep the browser copy byte-for-byte identical (tested). */ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('buffer').Buffer); + else root.ClipCascadeBinary = factory(null); +})(typeof globalThis !== 'undefined' ? globalThis : this, function(NodeBuffer) { + 'use strict'; + const FORMAT = 'ccb1'; + const CONTENT_TYPE = 'application/octet-stream'; + const bytes = value => value instanceof Uint8Array ? value : + ArrayBuffer.isView(value) ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) : new Uint8Array(value); + const utf8 = value => new TextEncoder().encode(value); + const text = value => new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(value); + function concat(parts) { + const result = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); + let offset = 0; + for (const part of parts) { result.set(part, offset); offset += part.length; } + return result; + } + function fromBase64(value) { + if (typeof value !== 'string') throw new Error('Invalid Base64'); + // Android's existing file reader uses Base64.DEFAULT (76 columns plus LF). + value = value.replace(/\s+/g, ''); + if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) throw new Error('Invalid Base64'); + const padding = value.indexOf('='); + if (padding >= 0 && (padding < value.length - 2 || !/^={1,2}$/.test(value.slice(padding)))) throw new Error('Invalid Base64'); + return NodeBuffer ? new Uint8Array(NodeBuffer.from(value, 'base64')) : Uint8Array.from(atob(value), c => c.charCodeAt(0)); + } + function toBase64(value) { + if (NodeBuffer) return NodeBuffer.from(value).toString('base64'); + let result = ''; + for (let i = 0; i < value.length; i += 8192) result += String.fromCharCode(...value.subarray(i, i + 8192)); + return btoa(result); + } + function pack(header, payload) { + const encoded = utf8(JSON.stringify(header)); + if (encoded.length > 65536) throw new Error('Binary header too large'); + const prefix = new Uint8Array(8); + prefix.set([67, 67, 66, 49]); + new DataView(prefix.buffer).setUint32(4, encoded.length); + return concat([prefix, encoded, bytes(payload)]); + } + function unpack(value) { + const data = bytes(value); + if (data.length < 8 || data[0] !== 67 || data[1] !== 67 || data[2] !== 66 || data[3] !== 49) throw new Error('Invalid binary frame'); + const length = new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(4); + if (length > 65536 || length > data.length - 8) throw new Error('Invalid binary header length'); + const header = JSON.parse(text(data.subarray(8, 8 + length))); + if (!header || typeof header !== 'object' || Array.isArray(header)) throw new Error('Invalid binary header'); + return [header, data.subarray(8 + length)]; + } + function originalContent(payload, kind) { + if (kind === 'text') return payload; + if (kind === 'image') return typeof payload === 'string' ? fromBase64(payload) : bytes(payload); + if (kind !== 'files') throw new Error('Unsupported content type'); + return typeof payload === 'string' ? Object.fromEntries(Object.entries(JSON.parse(payload)) + .map(([name, value]) => [name, fromBase64(value)])) : payload; + } + function legacyContent(payload, kind) { + if (kind === 'text' || typeof payload === 'string') return payload; + if (kind === 'image') return toBase64(payload); + return JSON.stringify(Object.fromEntries(Object.entries(payload).map(([name, value]) => [name, toBase64(value)]))); + } + function contentParts(payload, kind) { + payload = originalContent(payload, kind); + if (kind === 'text') return [{type: kind, files: null}, [utf8(payload)]]; + if (kind === 'image') return [{type: kind, files: null}, [payload]]; + return [{type: kind, files: Object.entries(payload).map(([name, value]) => [name, value.length])}, Object.values(payload)]; + } + async function encodeContent(payload, kind, encrypt = null, onRawSize = () => {}, checkCurrent = () => {}) { + const [header, parts] = contentParts(payload, kind); + const prefix = pack(header, new Uint8Array(0)); + const raw = new Uint8Array(prefix.length + parts.reduce((sum, part) => sum + part.length, 0)); + raw.set(prefix); + let position = prefix.length; + for (const part of parts) { + for (let offset = 0; offset < part.length; offset += 1024 * 1024) { + checkCurrent(); + const block = part.subarray(offset, offset + 1024 * 1024); + raw.set(block, position); + position += block.length; + if (raw.length > 1024 * 1024) await new Promise(resolve => setTimeout(resolve, 0)); + } + } + checkCurrent(); + onRawSize(raw.length); + return encrypt ? bytes(await encrypt(raw)) : raw; + } + async function decodeContent(raw, kind, decrypt = null, limit = null, original = false) { + if (Number.isSafeInteger(limit) && limit >= 0 && raw.length > limit + 65536 + 64) throw new Error('Binary content exceeds receive limit'); + if (decrypt) raw = await decrypt(bytes(raw)); + const [header, content] = unpack(raw); + if (header.type !== kind) throw new Error('Content type mismatch'); + if (Number.isSafeInteger(limit) && limit >= 0 && content.length > limit) throw new Error('Binary content exceeds receive limit'); + if (kind === 'text') return text(content); + if (kind === 'image') return original ? content : toBase64(content); + if (kind !== 'files' || !Array.isArray(header.files)) throw new Error('Invalid binary content'); + const result = Object.create(null); + let offset = 0; + for (const entry of header.files) { + if (!Array.isArray(entry) || entry.length !== 2) throw new Error('Invalid file entry'); + const [name, length] = entry; + if (typeof name !== 'string' || !name || name === '.' || name === '..' || /[/\\\0]/.test(name) || Object.prototype.hasOwnProperty.call(result, name) || !Number.isSafeInteger(length) || length < 0 || length > content.length - offset) throw new Error('Invalid file entry'); + result[name] = original ? content.subarray(offset, offset + length) : toBase64(content.subarray(offset, offset + length)); + offset += length; + } + if (offset !== content.length) throw new Error('Trailing binary content'); + return original ? result : JSON.stringify(result); + } + const encodeFrame = (payload, kind, metadata, encrypted = false) => pack({type: kind, metadata, encrypted}, payload); + function decodeFrame(data) { + const [header, payload] = unpack(data); + if (!['text', 'image', 'files'].includes(header.type) || typeof header.encrypted !== 'boolean') throw new Error('Invalid binary envelope'); + const meta = header.metadata; + if (!Number.isSafeInteger(meta?.contentSizeBytes) || meta.contentSizeBytes < 0) throw new Error('Invalid binary content size'); + if (Object.prototype.hasOwnProperty.call(meta, 'totalFragments')) { + if (!Number.isSafeInteger(meta.totalFragments) || !Number.isSafeInteger(meta.index) + || meta.totalFragments < 1 || meta.totalFragments > 65536 || meta.index < 0 || meta.index >= meta.totalFragments + || meta.isFragmented !== (meta.totalFragments > 1) || typeof meta.id !== 'string' || !meta.id + || payload.length > 15360) throw new Error('Invalid binary fragments'); + } + return {...header, payload, _cc_binary: true}; + } + function fragments(data, size = 15360) { + const result = []; + for (let i = 0; i < data.length; i += size) result.push(data.subarray(i, i + size)); + return result.length ? result : [new Uint8Array(0)]; + } + function recipients(plan) { + if (plan?.format !== FORMAT || !Array.isArray(plan.recipients) || plan.recipients.length > 256) return null; + if (!plan.recipients.every(peer => typeof peer.id === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(peer.id) && typeof peer.binary === 'boolean')) return null; + if (new Set(plan.recipients.map(peer => peer.id)).size !== plan.recipients.length) return null; + return {binary: plan.recipients.filter(p => p.binary).map(p => p.id), legacy: plan.recipients.filter(p => !p.binary).map(p => p.id)}; + } + return {FORMAT, CONTENT_TYPE, bytes, concat, fromBase64, toBase64, pack, unpack, encodeContent, decodeContent, encodeFrame, decodeFrame, fragments, recipients, originalContent, legacyContent, contentParts}; +}); 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 1a50ae58c..6c10e3afc 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 @@ -52,6 +52,7 @@ const SELECTORS = { const ENDPOINTS = { whoami: "/whoami", + defaultPasswordStatus: "/admin/default-password-status", logout: "/logout", logoutAllDevices: "/logout-session", csrfToken: "/csrf-token", @@ -82,12 +83,19 @@ let CURRENT_MODE = "P2S"; // STOMP references for P2S let stompClient = null; +let clipboardSendGeneration = 0; // P2P references let p2pSignalingWebSocket = null; let myPeerId = null; let peerConnections = {}; // key = peerId, value = RTCPeerConnection let dataChannels = {}; // key = peerId, value = RTCDataChannel +const peerReceiveLimits = new Map(); +// The web client monitors raw messages and has no local receive limit. +const P2P_MONITOR_CAPABILITIES = JSON.stringify({ + _cc_keepalive: true, + capabilities: { version: 1, transferFormats: ["ccb1"], receiveLimitBytes: null }, +}); let heartbeatIntervalId = null; // for 20s keep-alive on P2P let stunConfig = { iceServers: [] }; // will be loaded from /stun-url const P2P_CONNECTION_TIMEOUT_MS = 90000; @@ -167,6 +175,7 @@ $(function () { showSection(SELECTORS.advanceLink); showSection(SELECTORS.changeUsernameLink); showSection(SELECTORS.adminSection); + initAdvancedNavigation(); } // Show the read-only server version to every signed-in user. @@ -191,6 +200,9 @@ $(function () { // Initialize event handlers initEventHandlers(); + if (userData.role === "ADMIN") { + showDefaultPasswordWarning(); + } // Auto-populate the default WebSocket/Signaling URL autoPopulateWebSocketURL(); @@ -223,6 +235,14 @@ function initEventHandlers() { $(SELECTORS.logoutLink).click(onLogoutClick); $(SELECTORS.logoutAllDevicesLink).click(onLogoutAllDevicesClick); + const defaultPasswordDialog = document.getElementById("default-password-dialog"); + $("#default-password-warning-change").click(onChangePasswordClick); + $("#default-password-change").click(() => { + defaultPasswordDialog.close(); + onChangePasswordClick(); + }); + $("#default-password-later").click(() => defaultPasswordDialog.close()); + // Admin Section $(SELECTORS.loadUsersBtn).click(loadAllUsers); $(SELECTORS.revokeAllSessionsBtn).click(revokeAllUserSessions); @@ -353,9 +373,9 @@ function onSendClick() { // Send to STOMP or P2P if (CURRENT_MODE === "P2S") { - sendTextToServerStomp(textVal); + sendTextToServerStomp(textVal).catch(error => showStatusMessage(`Send failed: ${error.message}`, "danger")); } else { - sendTextToPeers(textVal); + sendTextToPeers(textVal).catch(error => showStatusMessage(`Send failed: ${error.message}`, "danger")); } // Log *locally* in the monitoring @@ -639,11 +659,18 @@ function fetchDonationStatus() { function connectStomp(generation) { const client = new StompJs.Client({ brokerURL: $(SELECTORS.brokerUrlInput).val(), + connectHeaders: { + "x-clipcascade-recipient-updates": "1", + "x-clipcascade-binary": "ccb1", + "x-clipcascade-receive-limit": "unlimited", + "x-clipcascade-no-self-echo": "true", // Sent text is already logged locally. + }, heartbeatIncoming: 20000, heartbeatOutgoing: 0, connectionTimeout: 15000, reconnectDelay: 0, // Both modes use the authenticated retry loop above. }); + client._ccRecipients = new ClipCascadeRelay.RelayRecipients(); stompClient = client; const current = () => monitoringRequested && generation === monitoringGeneration && stompClient === client; client.onConnect = () => { @@ -652,11 +679,19 @@ function connectStomp(generation) { updateMonitoringControls(true); showStatusMessage("Connected to WebSocket server (P2S).", "success"); client.subscribe(ENDPOINTS.websocketReceive, (res) => { - if (current()) displayIncomingMessage(JSON.parse(res.body)); + if (!current()) return; + const binary = res.headers["content-type"]?.split(";")[0] === ClipCascadeBinary.CONTENT_TYPE; + if (binary) { + displayBinaryMessage(ClipCascadeBinary.decodeFrame(res.binaryBody)).catch(error => console.warn("Invalid binary clipboard", error)); + } else { + const message = JSON.parse(res.body); + if (!client._ccRecipients.accept(message)) displayIncomingMessage(message); + } }); }; client.onWebSocketClose = () => { if (!current()) return; + client._ccRecipients.reset(); stompClient = null; client.deactivate(); scheduleMonitoringReconnect(generation); @@ -675,19 +710,64 @@ function connectStomp(generation) { } function disconnectStomp() { + clipboardSendGeneration++; const client = stompClient; stompClient = null; - if (client) client.deactivate(); + if (client) { client._ccRecipients.reset(); client.deactivate(); } +} + +async function binaryRelayRecipients(client) { + const cached = client._ccRecipients.get(); + if (cached) return cached; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch('/clipbinary/recipients', {credentials: 'same-origin', cache: 'no-store', signal: controller.signal}); + const fallback = response.ok ? ClipCascadeBinary.recipients(await response.json()) : null; + return client._ccRecipients.get() || fallback; + } catch (_) { return null; } + finally { clearTimeout(timer); } } -function sendTextToServerStomp(textVal) { - if (!stompClient || !stompClient.connected) return; - stompClient.publish({ +async function sendTextToServerStomp(textVal) { + const generation = ++clipboardSendGeneration; + const client = stompClient; + if (!client?.connected) return; + const message = textClipboardMessage(textVal); + const plan = await binaryRelayRecipients(client); + if (generation !== clipboardSendGeneration || client !== stompClient || !client.connected) return; + if (plan?.binary.length) { + const raw = await ClipCascadeBinary.encodeContent(textVal, 'text'); + if (generation !== clipboardSendGeneration || client !== stompClient || !client.connected) return; + client.publish({destination: '/app/clipbinary', + headers: {'content-type': ClipCascadeBinary.CONTENT_TYPE, 'x-clipcascade-recipients': plan.binary.join(',')}, + binaryBody: ClipCascadeBinary.encodeFrame(raw, 'text', message.metadata)}); + } + if (!plan || plan.legacy.length) client.publish({ destination: ENDPOINTS.websocketSend, - body: JSON.stringify({ payload: textVal, type: "text" }), + headers: plan ? {'x-clipcascade-recipients': plan.legacy.join(',')} : {}, + body: JSON.stringify(message), }); } +async function displayBinaryMessage(message) { + let payload; + if (message.metadata?.isFragmented === true) { + // Log each wire fragment immediately; monitoring does not reassemble transfers. + payload = JSON.stringify({format: 'ccb1', encrypted: message.encrypted, + encoding: 'base64', data: ClipCascadeBinary.toBase64(message.payload)}); + } else if (message.encrypted) { + const raw = message.payload; + const n = raw[0]; + if (!Number.isInteger(n) || n < 8 || n > 32 || raw.length < 1 + n + 16) throw new Error('Invalid encrypted binary payload'); + // The web monitor has no encryption key. Preserve its encrypted-envelope view. + payload = JSON.stringify({format: 'ccb1', nonce: ClipCascadeBinary.toBase64(raw.subarray(1, 1 + n)), + tag: ClipCascadeBinary.toBase64(raw.subarray(1 + n, 1 + n + 16)), + ciphertext: ClipCascadeBinary.toBase64(raw.subarray(1 + n + 16))}); + } else payload = await ClipCascadeBinary.decodeContent(message.payload, message.type); + displayIncomingMessage({payload, type: message.type, metadata: message.metadata}); +} + /* ----------------------------------------- ========== P2P WEBRTC ========== ----------------------------------------- */ @@ -725,6 +805,9 @@ async function connectP2P(generation) { } function closePeerConnections() { + if (heartbeatIntervalId) clearInterval(heartbeatIntervalId); + heartbeatIntervalId = null; + peerReceiveLimits.clear(); roomPeers.clear(); pendingPeerList = null; for (const peerId in peerConnections) disposePeerConnection(peerId); @@ -736,6 +819,7 @@ function closePeerConnections() { } function disconnectP2P() { + clipboardSendGeneration++; const socket = p2pSignalingWebSocket; p2pSignalingWebSocket = null; if (socket) socket.close(); @@ -803,6 +887,7 @@ function cancelPeerConnectionDeadline(peerId) { } function disposePeerConnection(peerId) { + peerReceiveLimits.delete(peerId); cancelPeerConnectionDeadline(peerId); clearTimeout(peerRetryTimers.get(peerId)); peerRetryTimers.delete(peerId); @@ -917,26 +1002,65 @@ function createPeerConnection(peerId, initiator) { } function setupDataChannel(peerId, channel) { + if (dataChannels[peerId] !== channel) peerReceiveLimits.delete(peerId); dataChannels[peerId] = channel; + channel.binaryType = "arraybuffer"; channel.onopen = () => { if (dataChannels[peerId] !== channel) return; cancelPeerConnectionDeadline(peerId); clearTimeout(peerRetryTimers.get(peerId)); peerRetryTimers.delete(peerId); peerRetryAttempts.delete(peerId); + try { + channel.send(P2P_MONITOR_CAPABILITIES); + } catch (error) { + console.warn("Could not send monitor capabilities:", error); + } + if (heartbeatIntervalId === null) { + heartbeatIntervalId = setInterval(() => { + for (const currentChannel of Object.values(dataChannels)) { + if (currentChannel.readyState !== "open") continue; + try { + currentChannel.send(P2P_MONITOR_CAPABILITIES); + } catch (error) { + console.warn("Could not send monitor capabilities:", error); + } + } + }, 20000); + } console.log("Data channel open with peer:", peerId); }; channel.onclose = () => { if (dataChannels[peerId] !== channel) return; + peerReceiveLimits.delete(peerId); console.log("Data channel closed with peer:", peerId); recoverPeerTransport(peerId, peerConnections[peerId]); }; channel.onmessage = (event) => { + if (dataChannels[peerId] !== channel) return; let msgObj; try { + if (typeof event.data !== 'string') { + displayBinaryMessage(ClipCascadeBinary.decodeFrame(event.data)) + .catch(error => console.warn('Invalid binary clipboard', error)); + return; + } msgObj = JSON.parse(event.data); } catch (e) { - console.warn("Received non-JSON or invalid data from peer:", event.data); + console.warn("Received invalid data from peer:", e); + return; + } + if (msgObj && msgObj._cc_keepalive === true) { + const capabilities = msgObj.capabilities; + channel._ccBinary = capabilities?.version === 1 && Array.isArray(capabilities.transferFormats) + && capabilities.transferFormats.includes("ccb1"); + if (capabilities?.version === 1 && + Object.prototype.hasOwnProperty.call(capabilities, "receiveLimitBytes")) { + const limit = capabilities.receiveLimitBytes; + if (limit === null || (Number.isSafeInteger(limit) && limit >= 0)) { + peerReceiveLimits.set(peerId, limit); + } + } return; } displayIncomingMessage(msgObj); @@ -1020,15 +1144,67 @@ function sendSignalingMessage(payload) { } } -function sendTextToPeers(textVal) { - const msg = { payload: textVal, type: "text" }; +function textClipboardMessage(textVal) { + return { + payload: textVal, + type: "text", + metadata: { contentSizeBytes: new TextEncoder().encode(textVal).length }, + }; +} + +async function sendTextToPeers(textVal) { + const generation = ++clipboardSendGeneration; + const recipients = Object.entries(dataChannels); + const msg = textClipboardMessage(textVal); + // Preserve the legacy P2P metadata contract as well as the new size field. + Object.assign(msg.metadata, { + id: typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" ? crypto.randomUUID() : + "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, c => { + const r = Math.floor(Math.random() * 16); + return (c === "x" ? r : (r & 3) | 8).toString(16); + }), + isFragmented: false, + index: 0, + totalFragments: 1, + combinedRawPayloadSizeInBytes: msg.metadata.contentSizeBytes, + }); const strMsg = JSON.stringify(msg); - for (const pid in dataChannels) { - const ch = dataChannels[pid]; - if (ch && ch.readyState === "open") { - ch.send(strMsg); + const binaryRaw = await ClipCascadeBinary.encodeContent(textVal, "text"); + if (generation !== clipboardSendGeneration) return; + let skipped = 0; + for (const [pid, ch] of recipients) { + if (generation !== clipboardSendGeneration) return; + if (ch === dataChannels[pid] && ch.readyState === "open") { + const limit = peerReceiveLimits.get(pid); + if (Number.isSafeInteger(limit) && msg.metadata.contentSizeBytes > limit) { + skipped++; + continue; + } + try { + if (ch._ccBinary) { + const parts = ClipCascadeBinary.fragments(binaryRaw); + for (let index = 0; index < parts.length; index++) { + const frame = ClipCascadeBinary.encodeFrame(parts[index], 'text', {...msg.metadata, + combinedRawPayloadSizeInBytes: binaryRaw.length, index, totalFragments: parts.length, isFragmented: parts.length > 1}); + const deadline = Date.now() + 30000; + while (ch.bufferedAmount + frame.length > 1024 * 1024) { + if (generation !== clipboardSendGeneration) return; + if (ch.readyState !== 'open' || Date.now() > deadline) throw new Error('Data channel stalled'); + await new Promise(resolve => setTimeout(resolve, 20)); + } + if (generation !== clipboardSendGeneration) return; + if (ch !== dataChannels[pid] || ch.readyState !== 'open') break; + ch.send(frame); + } + } else ch.send(strMsg); + } catch (error) { + console.error("Could not send text to peer:", pid, error); + } } } + if (skipped) { + showStatusMessage(`Text skipped for ${skipped} peer(s): exceeds their receive limit.`, "warning"); + } } /* ----------------------------------------- @@ -1099,8 +1275,22 @@ function onChangeUsernameClick() { }); } +async function showDefaultPasswordWarning() { + try { + const status = await $.getJSON(ENDPOINTS.defaultPasswordStatus); + document.getElementById("default-password-warning").hidden = !status.usingDefaultPassword; + const dialog = document.getElementById("default-password-dialog"); + if (status.showPrompt && typeof dialog.showModal === "function") { + dialog.showModal(); + } + } catch (error) { + console.error("Failed to check the default admin password:", error); + } +} + function onChangePasswordClick() { const newPassword = prompt("Enter your new password:"); + if (newPassword === null) return; if (!newPassword) { alert("Password cannot be empty."); return; @@ -1114,6 +1304,7 @@ function onChangePasswordClick() { data: JSON.stringify({ newPassword: hashedPassword }), success: (res) => { alert(res); + window.location.href = "/login?expired"; }, error: (err) => { alert("Failed to update password"); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/relay-recipients.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/relay-recipients.js new file mode 100644 index 000000000..86697c2c9 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/relay-recipients.js @@ -0,0 +1,31 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(require('./BinaryProtocol')); + else root.ClipCascadeRelay = factory(root.ClipCascadeBinary); +})(typeof globalThis !== 'undefined' ? globalThis : this, function(binary) { +const {recipients} = binary; + +// Each WebSocket connection owns one cache. Full snapshots repair missed updates. +class RelayRecipients { + constructor(clock = () => Date.now()) { + this.clock = clock; + this.reset(); + } + reset() { this.state = null; } + accept(message) { + if (message?._cc_control !== 'relay-recipients') return false; + if (message.version !== 1 || !Number.isSafeInteger(message.revision) || message.revision < 0 + || typeof message.epoch !== 'string' || !message.epoch || message.epoch.length > 128) return true; + if (this.state && (this.state.epoch !== message.epoch || message.revision <= this.state.revision)) return true; + const plan = recipients(message); + const unique = plan && new Set([...plan.binary, ...plan.legacy]).size === plan.binary.length + plan.legacy.length; + this.state = {epoch: message.epoch, revision: message.revision, received: this.clock(), plan: unique ? plan : null}; + return true; + } + get() { + if (!this.state?.plan || this.clock() - this.state.received >= 60000) return null; + return {binary: [...this.state.plan.binary], legacy: [...this.state.plan.legacy]}; + } +} + +return {RelayRecipients}; +}); diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html index 82448d122..fce1e25d8 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html @@ -12,7 +12,7 @@ ClipCascade - + @@ -68,14 +68,7 @@
  • - - Advance - + Advanced
  • @@ -98,7 +91,13 @@ -
    +
    +
    + + + +

    Change the default admin password

    +

    Anyone who can reach this server can sign in as administrator with the published password.

    +

    Change it before making your server accessible to others.

    + + +
    + @@ -387,6 +410,9 @@

    Edit User

    + + + diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html index bc8037475..20e3c0e21 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html @@ -1,812 +1,166 @@ - - - - - - - - - - - - - ClipCascade - Advance - - - - - - - - - - -
    - -
    - + +
    +
    + + + + + + + + + + + + + + + + + + + + + + +
    UsernameEnabledMarked for DeletionFirst SignupLast LoginSignup IPLast Login IPFailed Login AttemptsLockout TimePassword Changed AtEmailOTPPhoneFirst NameLast NameNotes
    +
    +
    +
    + + +
    +

    WebSocket Stats

    + +
    +
    + + +
    +

    Online Users

    +
    + + + +
    +
    + + + + + + + + +
    UsernameOpen Connections
    +
    +
    +
    + + +
    +

    Brute Force Protection Tracker Snapshot

    +
    + + -

    ClipCascade - Advance

    - - - -
    -

    User Details

    -
    - - -
    -
    - - - - - - - - - - - - - - - - - - - - - - -
    UsernameEnabledMarked for DeletionFirst SignupLast LoginSignup IPLast Login IPFailed Login AttemptsLockout TimePassword Changed AtEmailOTPPhoneFirst NameLast NameNotes
    -
    -
    -
    - - -
    -

    WebSocket Stats

    - -
    -
    + +
    - -
    -

    Online Users

    -
    - +
    + + + + + + + + + + + + + +
    UsernameSource IPAttempts from IPIP Lock CountIP Lock ExpiresUnique IP AddressesUser Lock Expires
    +
    +
    +
    + + +
    +

    Unlock User

    +
    + +
    +
    + -
    -
    - - - - - - - - -
    UsernameOpen Connections
    -
    -
    -
    - - -
    -

    Brute Force Protection Tracker Snapshot

    -
    - - - -
    - -
    - - - - - - - - - - - - - -
    UsernameSource IPAttempts from IPIP Lock CountIP Lock ExpiresUnique IP AddressesUser Lock Expires
    -
    -
    -
    - - -
    -

    Unlock User

    - - -
    -
    - - -
    -
    - -
    -
    - -
    - - -
    -

    Server Time

    - -
    -
    - - -
    -

    Environment Variables

    -
    -

    + +
    + + +
    +

    Server Time

    + +
    +
    + + +
    +

    Environment Variables

    +
    +

    - - - - - - - - +
    +
    diff --git a/ClipCascade_Server/docker-compose/docker-compose-limitless-data-transfer.yml b/ClipCascade_Server/docker-compose/docker-compose-limitless-data-transfer.yml index 547881788..a9dcbbac9 100644 --- a/ClipCascade_Server/docker-compose/docker-compose-limitless-data-transfer.yml +++ b/ClipCascade_Server/docker-compose/docker-compose-limitless-data-transfer.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: clipcascade: image: sathvikrao/clipcascade:latest diff --git a/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml b/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml index 517a99c72..a3063f4d4 100644 --- a/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml +++ b/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: clipcascade: image: sathvikrao/clipcascade:latest @@ -45,7 +43,9 @@ services: CC_MAX_WS_GLOBAL_CONNECTIONS: -1 postgresdb: - image: postgres:latest + # This volume layout uses PostgreSQL 17. Keep the existing database major + # version on upgrades until you have migrated its data. + image: postgres:17 container_name: postgresdb restart: always environment: diff --git a/ClipCascade_Server/docker-compose/docker-compose-stomp-external-broker.yml b/ClipCascade_Server/docker-compose/docker-compose-stomp-external-broker.yml index b008bffdd..afc6a15c7 100644 --- a/ClipCascade_Server/docker-compose/docker-compose-stomp-external-broker.yml +++ b/ClipCascade_Server/docker-compose/docker-compose-stomp-external-broker.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: clipcascade: image: sathvikrao/clipcascade:latest diff --git a/ClipCascade_Server/docker-compose/docker-compose.yml b/ClipCascade_Server/docker-compose/docker-compose.yml index 332f80057..350a3769d 100644 --- a/ClipCascade_Server/docker-compose/docker-compose.yml +++ b/ClipCascade_Server/docker-compose/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - # # 🌐 GitHub Repository: https://github.com/Sathvik-Rao/ClipCascade # # 🐳 Docker Compose configuration for deploying ClipCascade Server. # # ⚠️ IMPORTANT: Update the default admin credentials after the first login. diff --git a/ClipCascade_Server/reverse-proxy/README.md b/ClipCascade_Server/reverse-proxy/README.md index f4b9ef0db..878b32606 100644 --- a/ClipCascade_Server/reverse-proxy/README.md +++ b/ClipCascade_Server/reverse-proxy/README.md @@ -184,6 +184,14 @@ generates those blocks. Start without custom locations so **both** WebSocket endpoints inherit the generated upgrade settings. If old custom locations exist, remove/fix them rather than only patching `/clipsocket`. +Before exposing this host, verify that the effective upstream location strips +client-supplied `Forwarded` and `X-Forwarded-Prefix` and rebuilds +`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, and +`X-Forwarded-Port` from trusted connection information. The generated proxy +configuration may append to the incoming `X-Forwarded-For`. The timeout +snippet does not sanitize headers. Use the bundled Nginx setup if you cannot +enforce this at your proxy. + For a nonstandard HTTPS port, prefer the bundled Nginx setup: the public Host and port must survive NPM's generated configuration, not merely its host port mapping. Do not use an HTTP upstream with NPM's scheme set to HTTPS. diff --git a/README.md b/README.md index 703ac0bab..5dc6d835f 100644 --- a/README.md +++ b/README.md @@ -476,17 +476,17 @@ If you encounter the `error: externally-managed-environment`, install the requir ##### Debian/Ubuntu: ``` -sudo apt install -y python3-xxhash python3-pyperclip python3-requests python3-websocket python3-pycryptodome python3-tk python3-pystray python3-pyfiglet python3-bs4 python3-plyer python3-aiortc python3-prompt-toolkit +sudo apt install -y python3-xxhash python3-pyperclip python3-requests python3-websocket python3-pycryptodome python3-tk python3-pystray python3-pyfiglet python3-bs4 python3-plyer python3-aiortc python3-prompt-toolkit python3-keyring python3-secretstorage ``` ##### Fedora: ``` -sudo dnf install -y python3-xxhash python3-pyperclip python3-requests python3-websocket-client python3-pycryptodomex python3-tkinter python3-pystray python3-pyfiglet python3-beautifulsoup4 python3-plyer python3-aiortc python3-prompt-toolkit +sudo dnf install -y python3-xxhash python3-pyperclip python3-requests python3-websocket-client python3-pycryptodomex python3-tkinter python3-pystray python3-pyfiglet python3-beautifulsoup4 python3-plyer python3-aiortc python3-prompt-toolkit python3-keyring python3-secretstorage ``` ##### Arch: ``` -sudo pacman -S --noconfirm python-xxhash python-pyperclip python-requests python-websocket-client python-pycryptodome tk python-pystray python-pyfiglet python-beautifulsoup4 python-plyer python-aiortc python-prompt_toolkit +sudo pacman -S --noconfirm python-xxhash python-pyperclip python-requests python-websocket-client python-pycryptodome tk python-pystray python-pyfiglet python-beautifulsoup4 python-plyer python-aiortc python-prompt_toolkit python-keyring ``` - If you encounter the `error: target not found: python-plyer`, install via `yay -S --noconfirm python-plyer` - If you encounter the `error: target not found: python-aiortc`, install via `yay -S --noconfirm python-aiortc` @@ -664,7 +664,7 @@ cd /path/to/clipcascade/src/ && sudo python3 main.py - Web pages follow your system's light/dark theme until you use the theme toggle. Your manual choice is saved in browser local storage and restored across page loads when storage is available. - Use your browser's install or **Add to Home Screen** option, where supported, to open the web dashboard as an app. -- 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. +- The Admin Panel user list and the Advanced section's User Details, Online Users, and BFA tracker tables support searching, sorting, and pagination. Select **Advanced** to open these tools within the dashboard. 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: @@ -1128,7 +1128,7 @@ Connection status notifications appear on the main login screen, outside advance Device preferences save as you change them, without requiring a successful login: notification settings, local clipboard size limit, send/receive clipboard, image/file sharing, password-storage preference, the default download folder on desktop and Android, and Android startup/periodic checks. Server address, credentials, encryption settings, and custom CA configuration are saved after successful login. -- **Local clipboard size limit**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. Set a local limit to test a suitable threshold for your device; it applies to incoming and outgoing clipboard content alongside server and platform limits. On the desktop login window and Android, choose **Bytes**, **KiB**, **MiB**, or **GiB**. Decimal values such as `1.5 MiB` are supported and rounded to the nearest byte; 1 KiB = 1024 bytes. Leave the value blank or select **No local limit** to remove the local limit. The Linux CLI accepts an integer byte count, or a blank value for no local limit. The server's `CC_MAX_MESSAGE_SIZE_IN_BYTES` setting can also set a limit below 1 MiB. +- **Local clipboard size limit**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. Set a local limit to test a suitable threshold for your device; it applies to incoming and outgoing clipboard content alongside server and platform limits. The local limit measures the original content size, excluding encoding and encryption overhead. On the desktop login window and Android, choose **Bytes**, **KiB**, **MiB**, or **GiB**. Decimal values such as `1.5 MiB` are supported and rounded to the nearest byte; 1 KiB = 1024 bytes. Leave the value blank or select **No local limit** to remove the local limit. The Linux CLI accepts an integer byte count, or a blank value for no local limit. The server's `CC_MAX_MESSAGE_SIZE_IN_BYTES` setting can also set a limit below 1 MiB. - **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. - **Send Clipboard**: Share clipboard changes from this device. Enabled by default. Disable it to stop sending while still receiving updates if **Receive Clipboard** is enabled. @@ -1154,6 +1154,11 @@ Device preferences save as you change them, without requiring a successful login #### Desktop (Specific): +- **Use OS credential storage**: Disabled by default. Enable it under **Show advanced settings → Security** to save login credentials in Windows Credential Manager, macOS Keychain, or a Linux Secret Service keyring instead of the local `DATA` file. When disabled, credentials use the existing `DATA` format without OS credential-store protection. The preference saves immediately, but the storage change takes effect only after successful manual login. Closing the login form or a failed login/save preserves the existing saved credentials. This option is independent of **Store Password Locally**. + > Note: On Linux, starting before OS login may leave the credential service unavailable, and a locked keyring may require an unlock popup after login. On macOS, an updated app may ask you to allow Keychain access again. + + If saved login data cannot be accessed at startup, choose **Retry** to try again, **Sign in** to log in manually, or **Exit** to close the app. Retrying OS credential storage may show an unlock or permission prompt. + - **Notify when clipboard content is received**: Disabled by default. Enable it in advanced settings to show notifications for content received from another device, including a text preview, image dimensions, or file names with a file icon. Previews may be visible on your lock screen. This setting is independent of connection status and update notifications. #### Android (Specific): @@ -1192,7 +1197,7 @@ You can adjust these settings in the **advanced settings** section on the login **Android:** Content can exceed Android's system clipboard transaction limit even when ClipCascade's local limit is disabled. If copying fails with a system-limit message, use a smaller image or text selection, or transfer large text as a file. Increasing ClipCascade's limit cannot bypass Android's restriction. -- **Files**: When files are received, a notification icon appears in the system tray (on desktop platforms). Since the clipboard does not store files, only their file paths are retained. +- **Files**: When files are received, a notification icon appears in the system tray (on desktop platforms). Received file contents are held in memory. Use the download action to save them to your device; newer clipboard updates can replace files waiting to be saved.