diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb9b07c..72b113e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,12 @@ on: jobs: lint-and-test: + name: ${{ matrix.os }} / Python 3.12 + runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 diff --git a/README.md b/README.md index be24ec5..82398fe 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # ClipSync Peer-to-peer clipboard sync for Windows, macOS, and Linux. Copy on one machine, -paste on another. No account, no cloud, no central server. The data never -leaves your devices. +paste on another. No account and no ClipSync-operated server. Syncthing may use +its encrypted public relays when devices cannot connect directly; relays cannot +read the TLS-encrypted traffic. Verified working across Windows, macOS, and Linux, across different networks. @@ -51,6 +52,9 @@ ciphertext. ## Install +Download the current Windows and Linux builds from the +[GitHub Releases page](https://github.com/offbyonebit/clipsync/releases), or install with Python: + ```bash pip install offbyonebit-clipsync clipsync @@ -102,7 +106,11 @@ for the icon to actually show up. 1. Tray icon -> **Add Device**. 2. On the second machine, do the same. 3. Scan the QR code with the webcam, or paste the device ID shown below it. -4. Both sides auto-accept and start syncing. +4. Approve the incoming request on the other device. Auto-accept is available + in Settings for trusted private networks, and is off by default. +5. Copy a short phrase on either device, then paste it on the other. The tray + distinguishes a local publish from Syncthing replication; it does not call + a clipboard delivered until a receiver has confirmed it. ## Settings @@ -110,6 +118,8 @@ for the icon to actually show up. Linux). - Show notifications. - Pause sync. +- Pause for 15 minutes, or use manual-send mode from the tray. +- Configurable text and image size limits. - Sync folder path. - Encryption passphrase. - View Syncthing logs. @@ -117,6 +127,24 @@ for the icon to actually show up. - Check for updates (compares against the latest GitHub release and opens the download page, no auto-install, no phone-home on startup). +Clipboard history opens from the tray or with `Ctrl+Alt+V` when the platform +supports global shortcuts. It supports search, arrow-key navigation, pinned +snippets, and bounded image thumbnails. Pinned items still follow the selected +auto-clear window. File transfers use unique IDs, retry failed receives after a +restart, and show "delivered" only after a receiver writes an acknowledgement. + +The tray status shows the latest local send and incoming paste separately. If +an incoming paste is waiting on the desktop clipboard, use **Retry Incoming +Clipboard Now** from the tray; ClipSync also retries automatically with a +capped backoff. The Connected Devices view shows each peer's connection and +folder replication status. **Settings → View Syncthing logs** opens transport +diagnostics. + +Settings can block text that matches common private-key, API-token, or +password-assignment patterns. This heuristic is off by default, can miss +secrets or flag examples, and does not inspect images. Clipboard history can +be disabled locally or set to auto-clear after a short interval. + Settings changes from the UI take effect immediately; hand-editing `settings.json` is also picked up, the file is watched for changes. diff --git a/clipsync/build.py b/clipsync/build.py index ce1541a..2ecb1fc 100644 --- a/clipsync/build.py +++ b/clipsync/build.py @@ -52,6 +52,11 @@ def _common_args(name: str) -> list[str]: # customtkinter ships TTF files that don't auto-detect. "--collect-data", "customtkinter", + # The OS keychain package is imported lazily by config.py. Collect its + # backends explicitly so Windows Credential Manager is present in the + # frozen app as well as in a source checkout. + "--collect-all", + "keyring", str(ENTRY_POINT), ] diff --git a/clipsync/clipboard.py b/clipsync/clipboard.py index d4aec60..2ca0cbc 100644 --- a/clipsync/clipboard.py +++ b/clipsync/clipboard.py @@ -28,10 +28,12 @@ import logging import os import queue +import re import subprocess import sys import threading import time +from dataclasses import dataclass from pathlib import Path import pyperclip @@ -73,6 +75,37 @@ class EncryptedPayloadError(RuntimeError): # Sentinel pushed onto the XFixes queue by stop() to unblock the OUT loop. _STOP_SENTINEL = object() +_INCOMING_RETRY_DELAYS = (0.25, 0.5, 1.0, 2.0, 4.0) +_INCOMING_RETRY_MAX_DELAY = 30.0 +_LIKELY_SECRET_PATTERNS = ( + re.compile(r"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----", re.IGNORECASE), + re.compile( + r"(?im)^\s*(?:password|passwd|pwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\s*[:=]\s*\S{6,}" + ), + re.compile(r"\bBearer\s+[A-Za-z0-9._~+/=-]{16,}", re.IGNORECASE), + re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), +) + + +def _incoming_retry_delay(attempts: int) -> float: + """Back off clipboard writes without abandoning the newest remote value.""" + exponent = min(max(attempts - 1, 0), 10) + return min(_INCOMING_RETRY_MAX_DELAY, 0.25 * (2**exponent)) + + +def _looks_like_secret(text: str) -> bool: + return any(pattern.search(text) for pattern in _LIKELY_SECRET_PATTERNS) + + +@dataclass +class _PendingIncoming: + value: str | bytes + kind: str + generation: int + attempts: int = 0 + next_attempt: float = 0.0 + def _try_start_xfixes_watcher() -> queue.SimpleQueue[object] | None: """Start an X11 XFixes clipboard-owner watcher. @@ -367,6 +400,19 @@ def _normalize_newlines(s: str) -> str: return s.replace("\r\n", "\n").replace("\r", "\n") +def _relative_age(timestamp: float) -> str: + seconds = max(0, int(time.time() - timestamp)) + if seconds < 60: + return "just now" + minutes = seconds // 60 + if minutes < 60: + return f"{minutes}m ago" + hours = minutes // 60 + if hours < 24: + return f"{hours}h ago" + return f"{hours // 24}d ago" + + def _read_image_from_system_clipboard() -> bytes | None: """Return PNG bytes from the system clipboard, or None if no image is present.""" if sys.platform in ("win32", "darwin"): @@ -481,11 +527,22 @@ def __init__(self, settings: config.Settings) -> None: self._last_synced: str | bytes | None = None self._lock = threading.Lock() self._last_read_error: str | None = None + self._last_payload_error: str | None = None self._last_write_error: str | None = None self._last_decrypt_error: str | None = None self._xfixes_queue: queue.SimpleQueue[object] | None = None self._clipboard_owner: _XlibClipboardOwner | None = None self._history = ClipboardHistory(settings) + # Text and images share one native clipboard. A single generation and + # pending delivery prevent an older retry of either type from + # overwriting a newer local or remote value. + self._generation = 0 + self._pending_incoming: _PendingIncoming | None = None + self._last_observed_clipboard: str | bytes | None = None + self._last_outbound_at: float | None = None + self._last_inbound_at: float | None = None + self._outbound_pending = False + self._replication_status = "Syncthing starting" @property def clipboard_file(self) -> Path: @@ -499,6 +556,7 @@ def clipboard_image_file(self) -> Path: def start(self) -> None: self._stop.clear() + self._history.start() self._seed_from_file() if sys.platform not in ("win32", "darwin"): _no_xfixes = os.environ.get("CLIPSYNC_NO_XFIXES") @@ -524,6 +582,7 @@ def start(self) -> None: def stop(self) -> None: self._stop.set() + self._history.stop() if self._observer is not None: try: self._observer.stop() @@ -554,6 +613,100 @@ def clear_history(self) -> None: """ self._history.clear() + def set_history_pinned(self, timestamp: float, pinned: bool) -> bool: + return self._history.set_pinned(timestamp, pinned) + + def refresh_settings(self) -> None: + self._history.refresh_settings() + + def send_current(self) -> None: + """Publish the current clipboard even when manual-send mode is enabled.""" + if self._is_paused(): + self._last_write_error = "Sync is paused; resume before sending" + return + self._out_tick(force=True) + + def set_replication_status(self, status: str) -> None: + with self._lock: + self._replication_status = status + + def status_snapshot(self) -> dict[str, object]: + with self._lock: + error = ( + self._last_decrypt_error or self._last_write_error or self._last_payload_error or self._last_read_error + ) + pending = self._pending_incoming + return { + "paused": self._is_paused(), + "paused_until": self._settings.get("paused_until", 0.0), + "manual_send": bool(self._settings.get("manual_send", False)), + "outbound_pending": self._outbound_pending, + "last_outbound_at": self._last_outbound_at, + "last_inbound_at": self._last_inbound_at, + "incoming_pending": pending is not None, + "incoming_kind": None if pending is None else pending.kind, + "incoming_attempts": 0 if pending is None else pending.attempts, + "workers": { + "outbound": self._poll_thread is not None and self._poll_thread.is_alive(), + "inbound": self._in_thread is not None and self._in_thread.is_alive(), + "file_watcher": self._observer is not None and self._observer.is_alive(), + }, + "replication": self._replication_status, + "error": error, + } + + def status_text(self) -> str: + status = self.status_snapshot() + if status["paused"]: + paused_until = status["paused_until"] + try: + remaining = ( + max(0, int(float(paused_until) - time.time())) if isinstance(paused_until, str | int | float) else 0 + ) + except (TypeError, ValueError): + remaining = 0 + if remaining: + return f"Status: paused for {max(1, (remaining + 59) // 60)}m" + return "Status: paused" + workers = status["workers"] + worker_names = {"outbound": "sender", "inbound": "receiver", "file_watcher": "file watcher"} + stopped = ( + [worker_names.get(str(name), str(name)) for name, running in workers.items() if not running] + if isinstance(workers, dict) + else [] + ) + if stopped: + return f"Status: clipboard {' and '.join(stopped)} stopped" + if status["incoming_pending"]: + action = "retrying" if status["incoming_attempts"] else "applying" + return f"Status: {action} incoming {status['incoming_kind']} clipboard" + if status["error"]: + return f"Status: {status['error']}" + if status["outbound_pending"]: + return "Status: publishing clipboard locally" + suffix = " · manual send" if status["manual_send"] else "" + outbound = status["last_outbound_at"] + inbound = status["last_inbound_at"] + activity_parts = [] + if isinstance(outbound, (int, float)): + activity_parts.append(f"sent {_relative_age(outbound)}") + if isinstance(inbound, (int, float)): + activity_parts.append(f"received {_relative_age(inbound)}") + activity = " · " + " · ".join(activity_parts) if activity_parts else "" + return f"Status: {status['replication']}{activity}{suffix}" + + def retry_incoming_now(self) -> bool: + """Retry a staged incoming clipboard value immediately from the tray.""" + with self._lock: + if self._is_paused(): + return False + pending = self._pending_incoming + if pending is None: + return False + pending.next_attempt = time.monotonic() + self._attempt_pending_incoming() + return True + def _passphrase(self) -> str: val = self._settings.get("encryption_passphrase") or "" return val if isinstance(val, str) else "" @@ -566,6 +719,10 @@ def _read_file(self) -> str | None: try: if not path.exists(): return None + max_bytes = self._max_text_bytes() + if path.stat().st_size > (max_bytes * 2) + 4096: + self._last_payload_error = f"Incoming text exceeds the {max_bytes} byte limit" + return None data = path.read_bytes() except OSError as exc: log.debug("File read failed: %s", exc) @@ -589,11 +746,19 @@ def _read_file(self) -> str | None: log.info("Decrypt recovered") self._last_decrypt_error = None try: + if len(decrypted) > self._max_text_bytes(): + self._last_payload_error = f"Incoming text exceeds the {self._max_text_bytes()} byte limit" + return None + self._last_payload_error = None return _normalize_newlines(decrypted.decode("utf-8")) except UnicodeDecodeError: log.warning("Decrypted clipboard data is not valid UTF-8; ignoring") return None try: + if len(data) > self._max_text_bytes(): + self._last_payload_error = f"Incoming text exceeds the {self._max_text_bytes()} byte limit" + return None + self._last_payload_error = None return _normalize_newlines(data.decode("utf-8")) except UnicodeDecodeError: log.warning("Clipboard file is not valid UTF-8 and not encrypted; ignoring") @@ -656,6 +821,10 @@ def _read_image_file(self) -> bytes | None: try: if not path.exists(): return None + max_bytes = self._max_image_bytes() + if path.stat().st_size > (max_bytes * 2) + 4096: + self._last_payload_error = f"Incoming image exceeds the {max_bytes} byte limit" + return None data = path.read_bytes() except OSError as exc: log.debug("Image file read failed: %s", exc) @@ -682,6 +851,10 @@ def _read_image_file(self) -> bytes | None: if not data.startswith(_PNG_HEADER): log.debug("IN [%s]: clipboard.png contains non-PNG data (%d bytes); skipping", _HOSTNAME, len(data)) return None + if len(data) > self._max_image_bytes(): + self._last_payload_error = f"Incoming image exceeds the {self._max_image_bytes()} byte limit" + return None + self._last_payload_error = None return data def _write_image_file(self, png_bytes: bytes) -> None: @@ -740,7 +913,24 @@ def _warn_if_locked_ciphertext(self, path: Path) -> None: ) def _is_paused(self) -> bool: - return bool(self._settings.get("sync_paused")) + paused_until = self._settings.get("paused_until", 0.0) + try: + temporarily_paused = float(paused_until) > time.time() + except (TypeError, ValueError): + temporarily_paused = False + return bool(self._settings.get("sync_paused")) or temporarily_paused + + def _max_text_bytes(self) -> int: + try: + return max(1024, int(self._settings.get("max_text_bytes", 1024 * 1024))) + except (TypeError, ValueError): + return 1024 * 1024 + + def _max_image_bytes(self) -> int: + try: + return max(1024, int(self._settings.get("max_image_bytes", 5 * 1024 * 1024))) + except (TypeError, ValueError): + return 5 * 1024 * 1024 def _read_clipboard(self) -> str | None: try: @@ -760,22 +950,21 @@ def _read_clipboard(self) -> str | None: return _normalize_newlines(value) def _write_clipboard(self, value: str) -> bool: - if self._clipboard_owner is not None: - try: - self._clipboard_owner.set(value) - log.debug("xlib clipboard set (%d chars)", len(value)) - if self._last_write_error is not None: - log.info("Clipboard write recovered") - self._last_write_error = None - return True - except Exception as exc: - msg = f"xlib owner: {type(exc).__name__}: {exc}" - if msg != self._last_write_error: - log.warning("Clipboard write failed (%s); falling back to pyperclip", msg) - self._last_write_error = msg - # fall through to pyperclip try: with _CLIPBOARD_LOCK: + if self._clipboard_owner is not None: + try: + self._clipboard_owner.set(value) + log.debug("xlib clipboard set (%d chars)", len(value)) + if self._last_write_error is not None: + log.info("Clipboard write recovered") + self._last_write_error = None + return True + except Exception as exc: + msg = f"xlib owner: {type(exc).__name__}: {exc}" + if msg != self._last_write_error: + log.warning("Clipboard write failed (%s); falling back to pyperclip", msg) + self._last_write_error = msg pyperclip.copy(value) log.debug("clipboard write (pyperclip) (%d chars)", len(value)) except Exception as exc: @@ -800,14 +989,21 @@ def _read_clipboard_image(self) -> bytes | None: def _write_clipboard_image(self, png_bytes: bytes) -> bool: try: with _CLIPBOARD_LOCK: - return _write_image_to_system_clipboard(png_bytes) + written = _write_image_to_system_clipboard(png_bytes) except Exception as exc: - log.debug("Image clipboard write failed: %s", exc) + self._last_write_error = f"Could not apply incoming image: {type(exc).__name__}: {exc}" + log.warning("Image clipboard write failed: %s", exc) return False + if not written: + self._last_write_error = "Could not apply incoming image to the system clipboard" + return False + self._last_write_error = None + return True def _out_loop(self) -> None: _last_heartbeat = time.monotonic() _HEARTBEAT_INTERVAL = 6.0 + was_paused = self._is_paused() # Initial tick captures whatever is on the clipboard at startup. try: @@ -817,6 +1013,7 @@ def _out_loop(self) -> None: log.exception("Error in OUT loop (initial tick)") while not self._stop.is_set(): + was_paused = self._refresh_pause_state(was_paused) if self._xfixes_queue is not None: # Event-driven path: block until clipboard owner changes (or stop). try: @@ -874,50 +1071,114 @@ def _out_loop(self) -> None: self._is_paused(), ) - def _out_tick(self) -> None: + def _out_tick(self, force: bool = False) -> None: + if self._settings.get("manual_send", False) and not force: + return + with self._lock: + observed_generation = self._generation + # Images take priority: if the clipboard has an image, sync it. image = self._read_clipboard_image() if image is not None: + if len(image) > self._max_image_bytes(): + self._last_write_error = f"Image exceeds the {self._max_image_bytes()} byte limit" + return with self._lock: + if observed_generation != self._generation: + return if image == self._last_synced: + self._last_observed_clipboard = image return + if self._pending_incoming is not None and image == self._last_observed_clipboard: + return + previous_last_synced = self._last_synced + self._generation += 1 + publish_generation = self._generation + self._pending_incoming = None + self._last_observed_clipboard = image + self._last_synced = image try: + with self._lock: + self._outbound_pending = True self._write_image_file(image) + with self._lock: + self._outbound_pending = False + self._last_outbound_at = time.time() + self._last_write_error = None + self._replication_status = "Waiting for Syncthing" + log.info("OUT [%s]: %d bytes image written", _HOSTNAME, len(image)) + self._history.add_image(image, "local") except EncryptedPayloadError: + with self._lock: + self._outbound_pending = False + if self._generation == publish_generation and self._last_synced == image: + self._last_synced = previous_last_synced reason = "Refusing to overwrite encrypted clipboard image file (cannot decrypt)" if reason != self._last_decrypt_error: log.warning("OUT [%s]: %s", _HOSTNAME, reason) self._last_decrypt_error = reason - return - except OSError: + except OSError as exc: + # Roll back too: _last_synced is the "already sent" guard, so + # leaving it set after a failed write means every later tick + # sees this image as synced and it is never retried. + with self._lock: + self._outbound_pending = False + self._last_write_error = f"Could not publish clipboard image: {exc}" + if self._generation == publish_generation and self._last_synced == image: + self._last_synced = previous_last_synced log.exception("OUT [%s]: Failed to write image file", _HOSTNAME) - return - with self._lock: - self._last_synced = image - log.info("OUT [%s]: %d bytes image written", _HOSTNAME, len(image)) return current = self._read_clipboard() if current is None or current == "": return + if self._settings.get("filter_likely_secrets", False) and _looks_like_secret(current): + self._last_write_error = "Text that looks like a secret was blocked by the privacy filter" + return + if len(current.encode("utf-8")) > self._max_text_bytes(): + self._last_write_error = f"Text exceeds the {self._max_text_bytes()} byte limit" + return with self._lock: + if observed_generation != self._generation: + return if current == self._last_synced: + self._last_observed_clipboard = current + return + if self._pending_incoming is not None and current == self._last_observed_clipboard: return + previous_last_synced = self._last_synced + self._generation += 1 + publish_generation = self._generation + self._pending_incoming = None + self._last_observed_clipboard = current + self._last_synced = current try: + with self._lock: + self._outbound_pending = True self._write_file(current) + with self._lock: + self._outbound_pending = False + self._last_outbound_at = time.time() + self._last_write_error = None + self._replication_status = "Waiting for Syncthing" + log.info("OUT [%s]: %d chars written", _HOSTNAME, len(current)) + self._history.add_entry(current, "local") except EncryptedPayloadError: + with self._lock: + self._outbound_pending = False + if self._generation == publish_generation and self._last_synced == current: + self._last_synced = previous_last_synced reason = "Refusing to overwrite encrypted clipboard file (cannot decrypt)" if reason != self._last_decrypt_error: log.warning("OUT [%s]: %s", _HOSTNAME, reason) self._last_decrypt_error = reason - return - except OSError: + except OSError as exc: + with self._lock: + self._outbound_pending = False + self._last_write_error = f"Could not publish clipboard text: {exc}" + if self._generation == publish_generation and self._last_synced == current: + self._last_synced = previous_last_synced log.exception("OUT [%s]: Failed to write clipboard file", _HOSTNAME) - return - with self._lock: - self._last_synced = current - log.info("OUT [%s]: %d chars written", _HOSTNAME, len(current)) - self._history.add_entry(current, "local") def _in_loop(self) -> None: """Drain _in_queue and apply remote file changes to the local clipboard. @@ -927,28 +1188,28 @@ def _in_loop(self) -> None: causes pool-exhaustion errors. This loop runs on a thread we own so watchdog events are always handled off the pool in bounded time. """ - _last_processed: dict[str, float] = {} while True: + with self._lock: + pending = self._pending_incoming + retry_timeout = None if pending is None else max(0.0, pending.next_attempt - time.monotonic()) + timeout = 0.5 if retry_timeout is None else min(0.5, retry_timeout) try: - path = self._in_queue.get(timeout=0.5) + path = self._in_queue.get(timeout=timeout) except queue.Empty: if self._stop.is_set(): break - continue - if not path: # sentinel posted by stop() - break - if self._stop.is_set(): - break - if self._is_paused(): - continue - now = time.monotonic() - if now - _last_processed.get(path, 0.0) < 0.1: - continue - _last_processed[path] = now - try: - self._on_file_changed(path) - except Exception: - log.exception("Error in IN loop") + else: + if not path: # sentinel posted by stop() + break + if self._stop.is_set(): + break + if not self._is_paused(): + try: + self._on_file_changed(path) + except Exception: + log.exception("Error in IN loop") + if not self._is_paused(): + self._attempt_pending_incoming() def _start_watcher(self) -> None: handler = _ClipboardFileHandler(self) @@ -975,29 +1236,103 @@ def _on_text_file_changed(self) -> None: content = self._read_file() if content is None: return - with self._lock: - if content == self._last_synced: - log.debug("IN [%s]: file changed but content already synced (%d chars)", _HOSTNAME, len(content)) - return - # Update _last_synced before the write so that the XFixes event - # triggered by pyperclip.copy() below sees no change in the OUT - # loop and does not re-read the clipboard. - self._last_synced = content - if self._write_clipboard(content): - log.info("IN [%s]: %d chars applied to clipboard", _HOSTNAME, len(content)) - self._history.add_entry(content, "remote") + if self._settings.get("filter_likely_secrets", False) and _looks_like_secret(content): + message = "Incoming text was blocked by the likely-secret filter" + if self._last_payload_error != message: + log.warning("IN [%s]: incoming text blocked by privacy filter", _HOSTNAME) + self._last_payload_error = message + return + self._stage_incoming(content, "text") def _on_image_file_changed(self) -> None: image = self._read_image_file() if image is None: return + self._stage_incoming(image, "image") + + def _stage_incoming(self, value: str | bytes, kind: str) -> None: with self._lock: - if image == self._last_synced: - log.debug("IN [%s]: image file changed but already synced (%d bytes)", _HOSTNAME, len(image)) + if value == self._last_synced: + log.debug("IN [%s]: %s file changed but content is already synced", _HOSTNAME, kind) return - self._last_synced = image - if self._write_clipboard_image(image): - log.info("IN [%s]: %d bytes image applied to clipboard", _HOSTNAME, len(image)) + if self._pending_incoming is not None and self._pending_incoming.value == value: + return + self._generation += 1 + self._pending_incoming = _PendingIncoming( + value=value, + kind=kind, + generation=self._generation, + next_attempt=time.monotonic(), + ) + self._attempt_pending_incoming() + + def _attempt_pending_incoming(self) -> None: + """Apply the newest remote value, retrying failures without stale writes.""" + now = time.monotonic() + with self._lock: + pending = self._pending_incoming + if pending is None or pending.next_attempt > now or pending.generation != self._generation: + return + # Keep the logical guard locked across the native write and commit. + # OUT takes a generation snapshot before reading, so any read that + # overlaps this operation is discarded after the generation bump. + if pending.kind == "image": + ok = isinstance(pending.value, bytes) and self._write_clipboard_image(pending.value) + else: + ok = isinstance(pending.value, str) and self._write_clipboard(pending.value) + if pending is not self._pending_incoming or pending.generation != self._generation: + return + pending.attempts += 1 + if ok: + self._generation += 1 + self._last_synced = pending.value + self._last_observed_clipboard = pending.value + self._last_inbound_at = time.time() + self._last_write_error = None + self._pending_incoming = None + committed = pending + else: + pending.next_attempt = now + _incoming_retry_delay(pending.attempts) + if pending.attempts == len(_INCOMING_RETRY_DELAYS) + 1: + self._last_write_error = ( + f"Could not apply incoming {pending.kind} to the system clipboard; retrying" + ) + log.warning( + "IN [%s]: %s clipboard write still failing after %d attempts; retrying with bounded backoff", + _HOSTNAME, + pending.kind, + pending.attempts, + ) + return + if committed.kind == "text" and isinstance(committed.value, str): + self._history.add_entry(committed.value, "remote") + log.info("IN [%s]: %d chars applied to clipboard", _HOSTNAME, len(committed.value)) + elif isinstance(committed.value, bytes): + self._history.add_image(committed.value, "remote") + log.info("IN [%s]: %d bytes image applied to clipboard", _HOSTNAME, len(committed.value)) + + def reconcile_latest(self) -> None: + """After resume, apply only the newest clipboard file by mtime.""" + candidates: list[tuple[int, str]] = [] + for kind, path in (("text", self.clipboard_file), ("image", self.clipboard_image_file)): + try: + candidates.append((path.stat().st_mtime_ns, kind)) + except OSError: + continue + if not candidates: + return + _mtime, kind = max(candidates, key=lambda item: (item[0], item[1] == "image")) + if kind == "image": + self._on_image_file_changed() + else: + self._on_text_file_changed() + + def _refresh_pause_state(self, was_paused: bool) -> bool: + """Reconcile changes that arrived while a timed or manual pause was active.""" + paused = self._is_paused() + if was_paused and not paused: + self.reconcile_latest() + return paused class _ClipboardFileHandler(FileSystemEventHandler): @@ -1016,6 +1351,9 @@ def __init__(self, sync: ClipboardSync) -> None: # Cache resolved targets once so _matches doesn't re-resolve per event. self._resolved_text = sync.clipboard_file.resolve() self._resolved_image = sync.clipboard_image_file.resolve() + self._debounce_lock = threading.Lock() + self._trailing_timers: dict[str, threading.Timer] = {} + self._latest_path: dict[str, str] = {} def _matches(self, path: str) -> bool: if Path(path).name not in self._target_names: @@ -1031,13 +1369,28 @@ def _dispatch(self, path: str) -> None: return now = time.monotonic() key = str(Path(path).name) - if now < self._debounce_until.get(key, 0.0): - return - self._debounce_until[key] = now + 0.1 + with self._debounce_lock: + deadline = self._debounce_until.get(key, 0.0) + if now < deadline: + self._latest_path[key] = path + if key not in self._trailing_timers: + timer = threading.Timer(deadline - now, self._dispatch_trailing, args=(key,)) + timer.daemon = True + self._trailing_timers[key] = timer + timer.start() + return + self._debounce_until[key] = now + 0.1 # Non-blocking: hand off to _in_loop so the watchdog thread pool # is never held by clipboard I/O (avoids pool exhaustion on Windows). self._sync._in_queue.put(path) + def _dispatch_trailing(self, key: str) -> None: + with self._debounce_lock: + path = self._latest_path.pop(key, None) + self._trailing_timers.pop(key, None) + if path is not None: + self._sync._in_queue.put(path) + def _path_str(self, path: str | bytes) -> str: """Decode watchdog paths safely; surrogateescape preserves non-UTF-8 bytes.""" if isinstance(path, str): diff --git a/clipsync/config.py b/clipsync/config.py index 1d455b9..37a73a3 100644 --- a/clipsync/config.py +++ b/clipsync/config.py @@ -8,6 +8,7 @@ from __future__ import annotations import contextlib +import hashlib import json import logging import os @@ -110,12 +111,19 @@ def assets_dir() -> Path: "start_on_login": False, "sync_folder": str(SYNC_FOLDER), "first_run_completed": False, - "encryption_passphrase": "", "auto_accept_incoming": False, "rejected_device_ids": [], "history_enabled": True, "history_max_items": 50, "history_auto_clear_minutes": 0, + "filter_likely_secrets": False, + "manual_send": False, + "paused_until": 0.0, + "max_text_bytes": 1 * 1024 * 1024, + "max_image_bytes": 5 * 1024 * 1024, + "history_shortcut": "++v", + "transfer_retention_days": 7, + "cleanup_delivered_transfers": True, "theme": "System", # Mirror this device's log into the shared folder so peers can see it. # Off by default: it is a debugging aid, and the sync folder is replicated @@ -127,6 +135,21 @@ def assets_dir() -> Path: } HISTORY_FILE = APP_DATA_DIR / "clipsync_history.json" +TRANSFER_STATE_FILE = APP_DATA_DIR / "transfers.json" +_PASSPHRASE_KEYRING_ACCOUNT = "encryption-passphrase" + + +class SecretStorageError(RuntimeError): + """Raised when the operating-system credential store is unavailable.""" + + +def _keyring_backend() -> Any: + """Return the optional keyring module without importing it at startup.""" + try: + import keyring + except ImportError as exc: + raise SecretStorageError("OS keychain support is not installed") from exc + return keyring class Settings: @@ -141,6 +164,7 @@ def __init__(self, path: Path = SETTINGS_FILE) -> None: self._path = path self._lock = threading.RLock() self._data: dict[str, Any] = dict(DEFAULT_SETTINGS) + self._legacy_passphrase: str | None = None self._mtime_ns: int = 0 self._load() @@ -164,13 +188,14 @@ def _load(self) -> None: if not isinstance(loaded, dict): logging.warning("Settings file did not contain a JSON object; using defaults") return + legacy = loaded.get("encryption_passphrase") + self._legacy_passphrase = legacy if isinstance(legacy, str) and legacy else None merged = dict(DEFAULT_SETTINGS) merged.update({k: v for k, v in loaded.items() if k in DEFAULT_SETTINGS}) if not merged.get("api_key"): merged["api_key"] = uuid.uuid4().hex self._data = merged - # Migrate any plaintext passphrase into secure storage. - self._maybe_migrate_passphrase() + self._migrate_legacy_passphrase_locked() # Only persist if the on-disk file is incomplete (missing a default # key), has an empty api_key that we just generated, or still holds a # plaintext passphrase that was just migrated. Otherwise leave the file @@ -191,18 +216,6 @@ def _load(self) -> None: except OSError: pass - def _maybe_migrate_passphrase(self) -> None: - """Move plaintext passphrases from settings.json into secure storage.""" - plaintext = self._data.get("encryption_passphrase", "") - if not plaintext or not isinstance(plaintext, str): - return - try: - from .secure_settings import migrate_plaintext_passphrase - - migrate_plaintext_passphrase(self, self._secure_namespace()) - except Exception: - logging.warning("Could not migrate plaintext passphrase", exc_info=True) - def _secure_namespace(self) -> str: """Stable namespace isolating secure storage per settings file.""" try: @@ -214,8 +227,15 @@ def _persist_locked(self) -> None: self._path.parent.mkdir(parents=True, exist_ok=True) tmp = self._path.with_name(f"{self._path.name}.{os.getpid()}.tmp") try: + persisted = dict(self._data) + if self._legacy_passphrase: + # Retain this only when the OS keychain is unavailable. This + # avoids turning an upgrade into unrecoverable encrypted data. + persisted["encryption_passphrase"] = self._legacy_passphrase + else: + persisted.pop("encryption_passphrase", None) with tmp.open("w", encoding="utf-8") as fh: - json.dump(self._data, fh, indent=2) + json.dump(persisted, fh, indent=2) set_file_permissions(tmp) os.replace(tmp, self._path) finally: @@ -243,34 +263,93 @@ def get(self, key: str, default: Any = None) -> Any: with self._lock: self._refresh_if_changed() if key == "encryption_passphrase": - in_memory = self._data.get(key, default) - if in_memory: - return in_memory - try: - from .secure_settings import get_passphrase - - stored = get_passphrase(self._secure_namespace()) - if stored is not None: - return stored - except Exception: - logging.warning("Could not read passphrase from secure storage", exc_info=True) + return self._get_passphrase_locked() return self._data.get(key, default) def set(self, key: str, value: Any) -> None: with self._lock: if key == "encryption_passphrase": - try: - from .secure_settings import set_passphrase - - set_passphrase(value if value else None, self._secure_namespace()) - except Exception: - logging.warning("Could not write passphrase to secure storage", exc_info=True) - # Keep the plaintext field empty; the passphrase lives in the - # OS keychain or the encrypted fallback file. - value = "" + self.set_passphrase(value) + return self._data[key] = value self._persist_locked() + def _get_passphrase_locked(self) -> str: + value = None + try: + value = _keyring_backend().get_password(APP_ID, self._keyring_account()) + except Exception as exc: + if self._legacy_passphrase: + logging.warning("OS keychain unavailable; retaining legacy encrypted-data access: %s", exc) + return self._legacy_passphrase + logging.warning("OS keychain unavailable: %s", exc) + if isinstance(value, str): + return value + if self._legacy_passphrase: + return self._migrate_legacy_passphrase_locked() + # Older releases used a different keychain account and an encrypted + # fallback file. Preserve access when upgrading an existing install. + from .secure_settings import get_passphrase + + previous = get_passphrase(self._secure_namespace()) + if not previous: + return "" + try: + _keyring_backend().set_password(APP_ID, self._keyring_account(), previous) + except Exception: + logging.warning("Could not migrate older secure storage; retaining access to its passphrase") + return previous + + def _migrate_legacy_passphrase_locked(self) -> str: + if not self._legacy_passphrase: + return "" + legacy = self._legacy_passphrase + try: + _keyring_backend().set_password(APP_ID, self._keyring_account(), legacy) + except Exception as exc: + logging.warning("Could not migrate passphrase into OS keychain: %s", exc) + return legacy + self._legacy_passphrase = None + self._persist_locked() + logging.info("Moved encryption passphrase from settings.json into the OS keychain") + return legacy + + def _keyring_account(self) -> str: + """Keep separate portable/test profiles from sharing a local secret.""" + profile = hashlib.sha256(str(self._path.resolve()).encode("utf-8")).hexdigest()[:16] + return f"{_PASSPHRASE_KEYRING_ACCOUNT}-{profile}" + + def get_passphrase(self) -> str: + """Return the encryption passphrase from the OS keychain.""" + with self._lock: + self._refresh_if_changed() + return self._get_passphrase_locked() + + def set_passphrase(self, value: Any) -> None: + """Store an encryption passphrase outside settings.json. + + New values are never written to the JSON settings file. Existing + plaintext values are migrated automatically on their first read. + """ + if not isinstance(value, str): + raise ValueError("Encryption passphrase must be text") + with self._lock: + try: + backend = _keyring_backend() + if value: + backend.set_password(APP_ID, self._keyring_account(), value) + elif backend.get_password(APP_ID, self._keyring_account()) is not None: + backend.delete_password(APP_ID, self._keyring_account()) + except Exception as exc: + raise SecretStorageError("Could not store passphrase in the OS keychain") from exc + from .secure_settings import set_passphrase + + # Clear the older account/fallback too so clearing the new account + # cannot cause a previously saved secret to reappear on next read. + set_passphrase(None, self._secure_namespace()) + self._legacy_passphrase = None + self._persist_locked() + def update(self, **kwargs: Any) -> None: with self._lock: self._data.update(kwargs) @@ -296,9 +375,12 @@ def reload(self) -> None: if not isinstance(loaded, dict): logging.warning("Settings file did not contain a JSON object; keeping in-memory state") return + legacy = loaded.get("encryption_passphrase") + self._legacy_passphrase = legacy if isinstance(legacy, str) and legacy else None merged = dict(DEFAULT_SETTINGS) merged.update({k: v for k, v in loaded.items() if k in DEFAULT_SETTINGS}) self._data = merged + self._migrate_legacy_passphrase_locked() def ensure_directories() -> None: diff --git a/clipsync/file_transfer.py b/clipsync/file_transfer.py index abd6490..760d102 100644 --- a/clipsync/file_transfer.py +++ b/clipsync/file_transfer.py @@ -12,11 +12,15 @@ from __future__ import annotations +import json import logging +import os +import queue import shutil import threading import time -from collections import OrderedDict +import uuid +from collections import OrderedDict, deque from collections.abc import Callable from pathlib import Path @@ -34,6 +38,8 @@ # Marks a file in the shared folder as encrypted. Stripped when writing the # plaintext out to the receiver's Downloads folder. ENCRYPTED_SUFFIX = ".csenc" +MAX_FILE_TRANSFER_BYTES = 1 * 1024 * 1024 * 1024 +MAX_INCOMING_FILES_PER_MINUTE = 30 class FileTransfer: @@ -42,11 +48,21 @@ class FileTransfer: def __init__( self, settings: config.Settings, - on_received: Callable[[Path, str], None], + on_received: Callable[[Path, str], bool | None], + expected_receivers: Callable[[], int] | None = None, + acknowledger_id: Callable[[], str] | None = None, ) -> None: self._settings = settings self._on_received = on_received + self._expected_receivers = expected_receivers or (lambda: 1) + self._acknowledger_id = acknowledger_id or (lambda: _HOSTNAME) self._observer: BaseObserver | None = None + self._stop = threading.Event() + self._receive_queue: queue.Queue[tuple[Path, str, int]] = queue.Queue() + self._worker: threading.Thread | None = None + self._state_lock = threading.RLock() + self._state: dict[str, dict[str, object]] = self._load_state() + self._received_at: deque[float] = deque() @property def files_dir(self) -> Path: @@ -57,6 +73,15 @@ def _passphrase(self) -> str: val = self._settings.get("encryption_passphrase") or "" return val if isinstance(val, str) else "" + @staticmethod + def _validate_outgoing_source(source: Path) -> int: + if not source.is_file() or source.is_symlink(): + raise ValueError("Only regular files can be sent") + size = source.stat().st_size + if size > MAX_FILE_TRANSFER_BYTES: + raise ValueError(f"File exceeds the {MAX_FILE_TRANSFER_BYTES // (1024 * 1024)} MiB transfer limit") + return size + def send(self, source: Path) -> Path: """Copy *source* into the shared folder under this host's subdirectory. @@ -68,53 +93,279 @@ def send(self, source: Path) -> Path: Returns the destination path. Raises OSError on failure. """ + transfer_id = uuid.uuid4().hex + passphrase = self._passphrase() + size = self._validate_outgoing_source(source) + self._set_state( + transfer_id, + name=source.name, + size=size, + status="queued", + created=time.time(), + acknowledgements=0, + expected_acknowledgements=self._receiver_count(), + source=str(source), + ) + return self._publish_transfer(transfer_id, source, size, passphrase) + + def _receiver_count(self) -> int: + try: + return max(0, int(self._expected_receivers())) + except (OSError, TypeError, ValueError): + log.warning("Could not determine how many devices should acknowledge the transfer") + return 0 + + def _publish_transfer(self, transfer_id: str, source: Path, size: int, passphrase: str) -> Path: dest_dir = self.files_dir / _HOSTNAME dest_dir.mkdir(parents=True, exist_ok=True) timestamp = time.strftime("%Y%m%d_%H%M%S") - passphrase = self._passphrase() - size = source.stat().st_size if passphrase: - dest = dest_dir / f"{timestamp}_{source.name}{ENCRYPTED_SUFFIX}" - tmp = dest.with_name(dest.name + ".part") + dest = dest_dir / f"{transfer_id}_{timestamp}_{source.name}{ENCRYPTED_SUFFIX}" + tmp = dest_dir / f".syncthing.{dest.name}.tmp" try: encrypt_file(source, tmp, passphrase) config.set_file_permissions(tmp) tmp.replace(dest) - except BaseException: + except Exception as exc: tmp.unlink(missing_ok=True) + self._set_state(transfer_id, status="failed", error=str(exc)) raise log.info("FILE OUT [%s]: %s (%d bytes, encrypted)", _HOSTNAME, source.name, size) + self._set_state(transfer_id, status="transferring", path=str(dest), source="", error="") return dest - dest = dest_dir / f"{timestamp}_{source.name}" - shutil.copy2(source, dest) - # copy2 preserves the source mode, so a world-readable original stayed - # world-readable inside the shared folder. - config.set_file_permissions(dest) + dest = dest_dir / f"{transfer_id}_{timestamp}_{source.name}" + tmp = dest_dir / f".syncthing.{dest.name}.tmp" + try: + shutil.copy2(source, tmp) + config.set_file_permissions(tmp) + tmp.replace(dest) + except OSError as exc: + tmp.unlink(missing_ok=True) + self._set_state(transfer_id, status="failed", error=str(exc)) + raise log.info("FILE OUT [%s]: %s (%d bytes)", _HOSTNAME, source.name, size) + self._set_state(transfer_id, status="transferring", path=str(dest), source="", error="") return dest def start(self) -> None: + self._stop.clear() self.files_dir.mkdir(parents=True, exist_ok=True) - handler = _FileReceiveHandler(on_received=self._on_received) + handler = _FileReceiveHandler(on_received=self._enqueue_receive, files_dir=self.files_dir) observer = Observer() observer.schedule(handler, str(self.files_dir), recursive=True) observer.start() self._observer = observer + self._worker = threading.Thread(target=self._receive_loop, name="clipsync-files", daemon=True) + self._worker.start() + self._recover_outgoing() + self._recover_pending(handler) + self.cleanup_delivered() log.debug("File transfer watcher started (watching %s)", self.files_dir) def stop(self) -> None: + self._stop.set() if self._observer is not None: self._observer.stop() self._observer.join(timeout=3) self._observer = None + self._receive_queue.put((Path(), "", -1)) + if self._worker is not None and self._worker.is_alive(): + self._worker.join(timeout=3) + self._worker = None + + def _load_state(self) -> dict[str, dict[str, object]]: + try: + data = json.loads(config.TRANSFER_STATE_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(data, dict): + return {} + return {str(key): value for key, value in data.items() if isinstance(value, dict)} + + def _persist_state_locked(self) -> None: + path = config.TRANSFER_STATE_FILE + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp") + tmp.write_text(json.dumps(self._state, indent=2), encoding="utf-8") + config.set_file_permissions(tmp) + tmp.replace(path) + + def _set_state(self, transfer_id: str, **updates: object) -> None: + with self._state_lock: + self._state.setdefault(transfer_id, {}).update(updates) + self._persist_state_locked() + + @staticmethod + def _transfer_id(path: Path) -> str: + candidate = path.name.split("_", 1)[0] + if len(candidate) == 32 and all(char in "0123456789abcdef" for char in candidate.lower()): + return candidate + import hashlib + + return hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:32] + + def _ack_path(self, transfer_id: str) -> Path: + try: + raw_id = self._acknowledger_id() + except (OSError, TypeError, ValueError): + raw_id = _HOSTNAME + safe_id = "".join(char for char in str(raw_id) if char.isalnum() or char in "-_") or _HOSTNAME + return self.files_dir / ".acks" / transfer_id / f"{safe_id}.ack" + + def _enqueue_receive(self, path: Path, sender: str) -> None: + if not self._ack_path(self._transfer_id(path)).exists(): + self._receive_queue.put((path, sender, 0)) + + def _receive_loop(self) -> None: + while not self._stop.is_set(): + path, sender, attempt = self._receive_queue.get() + if attempt < 0 or self._stop.is_set(): + return + now = time.monotonic() + while self._received_at and self._received_at[0] <= now - 60: + self._received_at.popleft() + if len(self._received_at) >= MAX_INCOMING_FILES_PER_MINUTE and self._stop.wait( + max(0.1, 60 - (now - self._received_at[0])) + ): + return + self._received_at.append(time.monotonic()) + self._receive_once(path, sender, attempt) + + def _receive_once(self, path: Path, sender: str, attempt: int) -> None: + try: + result = self._on_received(path, sender) + if result is False: + raise OSError("receiver did not save the file") + except Exception: + if attempt < 4 and not self._stop.is_set(): + timer = threading.Timer( + 2**attempt, + self._receive_queue.put, + args=((path, sender, attempt + 1),), + ) + timer.daemon = True + timer.start() + else: + log.exception("File receive failed after %d attempts: %s", attempt + 1, path) + return + self._write_ack(path) + + def _write_ack(self, path: Path, attempt: int = 0) -> None: + if self._stop.is_set(): + return + ack = self._ack_path(self._transfer_id(path)) + tmp = ack.with_name(f".syncthing.{ack.name}.{os.getpid()}.tmp") + try: + ack.parent.mkdir(parents=True, exist_ok=True) + tmp.write_text(str(time.time()), encoding="ascii") + config.set_file_permissions(tmp) + tmp.replace(ack) + except OSError: + tmp.unlink(missing_ok=True) + if attempt < 4 and not self._stop.is_set(): + timer = threading.Timer(2**attempt, self._write_ack, args=(path, attempt + 1)) + timer.daemon = True + timer.start() + else: + log.exception("Could not acknowledge received file after %d attempts: %s", attempt + 1, path) + + def _recover_pending(self, handler: _FileReceiveHandler) -> None: + for sender_dir in self.files_dir.iterdir(): + if not sender_dir.is_dir() or sender_dir.name in {_HOSTNAME, ".acks"}: + continue + for path in sender_dir.iterdir(): + handler._handle(path) + + def _recover_outgoing(self) -> None: + for transfer_id, item in list(self._state.items()): + if item.get("status") != "queued": + continue + source = Path(str(item.get("source", ""))) + try: + size = self._validate_outgoing_source(source) + except (OSError, ValueError): + self._set_state(transfer_id, status="failed", error="source file is no longer available") + continue + try: + self._publish_transfer(transfer_id, source, size, self._passphrase()) + except OSError: + log.exception("Could not recover queued transfer %s", transfer_id) + + def status_snapshot(self) -> list[dict[str, object]]: + with self._state_lock: + changed = False + for transfer_id, item in self._state.items(): + ack_dir = self.files_dir / ".acks" / transfer_id + acknowledgements = len(list(ack_dir.glob("*.ack"))) if ack_dir.exists() else 0 + expected = item.get("expected_acknowledgements", 1) + expected_count = expected if isinstance(expected, int) else 1 + if expected_count <= 0: + refreshed_count = self._receiver_count() + if refreshed_count > 0: + expected_count = refreshed_count + item["expected_acknowledgements"] = refreshed_count + changed = True + if expected_count > 0 and acknowledgements >= expected_count and item.get("status") != "delivered": + item["status"] = "delivered" + item["delivered_at"] = time.time() + changed = True + if item.get("acknowledgements") != acknowledgements: + item["acknowledgements"] = acknowledgements + changed = True + if changed: + self._persist_state_locked() + return [dict(item, id=transfer_id) for transfer_id, item in self._state.items()] + + def status_text(self) -> str: + items = self.status_snapshot() + pending = sum(item.get("status") in {"queued", "transferring"} for item in items) + delivered = sum(item.get("status") == "delivered" for item in items) + failed = sum(item.get("status") == "failed" for item in items) + suffix = f" · {failed} failed" if failed else "" + return f"Transfers: {pending} awaiting acknowledgement · {delivered} delivered{suffix}" + + def cleanup_delivered(self) -> None: + if not self._settings.get("cleanup_delivered_transfers", True): + return + try: + days = max(1, int(self._settings.get("transfer_retention_days", 7))) + except (TypeError, ValueError): + days = 7 + cutoff = time.time() - days * 86400 + expired_ids: list[str] = [] + for item in self.status_snapshot(): + delivered_at = item.get("delivered_at") + if item.get("status") != "delivered" or not isinstance(delivered_at, (int, float)) or delivered_at > cutoff: + continue + path = Path(str(item.get("path", ""))) + try: + if path.is_file() and path.resolve().is_relative_to((self.files_dir / _HOSTNAME).resolve()): + path.unlink() + except OSError: + log.warning("Could not clean up delivered transfer %s", item.get("id")) + continue + transfer_id = str(item.get("id", "")) + ack_dir = self.files_dir / ".acks" / transfer_id + try: + if ack_dir.is_dir(): + shutil.rmtree(ack_dir) + except OSError: + log.warning("Could not clean up acknowledgements for transfer %s", transfer_id) + continue + expired_ids.append(transfer_id) + if expired_ids: + with self._state_lock: + for transfer_id in expired_ids: + self._state.pop(transfer_id, None) + self._persist_state_locked() class _FileReceiveHandler(FileSystemEventHandler): """Watch the files/ tree and fire on_received for files from remote hosts.""" - def __init__(self, on_received: Callable[[Path, str], None]) -> None: + def __init__(self, on_received: Callable[[Path, str], bool | None], files_dir: Path | None = None) -> None: super().__init__() self._on_received = on_received # Guard against duplicate events (watchdog can fire multiple times for @@ -127,22 +378,73 @@ def __init__(self, on_received: Callable[[Path, str], None]) -> None: self._seen: OrderedDict[str, bool] = OrderedDict() self._seen_lock = threading.Lock() self._seen_max = 1000 + self._deferred_keys: set[str] = set() + self._files_dir = files_dir.resolve() if files_dir is not None else None + self._received_at: deque[float] = deque() + self._deferred: deque[tuple[Path, str]] = deque() + self._defer_timer: threading.Timer | None = None + + def _within_rate_limit(self) -> bool: + now = time.monotonic() + cutoff = now - 60 + while self._received_at and self._received_at[0] <= cutoff: + self._received_at.popleft() + if len(self._received_at) >= MAX_INCOMING_FILES_PER_MINUTE: + return False + self._received_at.append(now) + return True def _handle(self, path: Path) -> None: # Expected layout: files// # Ignore files directly under files/ (no host subdirectory) and our own. + if self._files_dir is not None: + try: + relative = path.resolve().relative_to(self._files_dir) + except OSError: + log.warning("Ignoring unreadable received file: %s", path) + return + except ValueError: + log.warning("Ignoring received file outside the transfer directory: %s", path) + return + if relative.parts and relative.parts[0] == ".acks": + return + if len(relative.parts) != 2: + log.warning("Ignoring unexpected received-file path: %s", path) + return sender = path.parent.name if not sender or sender == _HOSTNAME: return # Ignore Syncthing temp files (.syncthing.*.tmp pattern). if path.name.startswith(".syncthing.") and path.name.endswith(".tmp"): return + try: + if path.is_symlink() or not path.is_file(): + log.warning("Ignoring non-regular received file: %s", path) + return + if path.stat().st_size > MAX_FILE_TRANSFER_BYTES: + log.warning("Ignoring received file above transfer limit: %s", path) + return + except OSError: + log.warning("Ignoring unreadable received file: %s", path) + return key = str(path) with self._seen_lock: + if key in self._deferred_keys: + return if key in self._seen: # Mark as recently used. self._seen.move_to_end(key) return + if not self._within_rate_limit(): + self._deferred_keys.add(key) + self._deferred.append((path, sender)) + if self._defer_timer is None: + delay = max(0.1, 60 - (time.monotonic() - self._received_at[0])) + self._defer_timer = threading.Timer(delay, self._retry_deferred) + self._defer_timer.daemon = True + self._defer_timer.start() + log.warning("Incoming file queued until the receive rate limit resets: %s", path.name) + return self._seen[key] = True while len(self._seen) > self._seen_max: self._seen.popitem(last=False) @@ -152,6 +454,20 @@ def _handle(self, path: Path) -> None: except Exception: log.exception("Error in file receive handler") + def _retry_deferred(self) -> None: + with self._seen_lock: + self._defer_timer = None + if not self._deferred: + return + path, _sender = self._deferred.popleft() + self._deferred_keys.discard(str(path)) + self._handle(path) + with self._seen_lock: + if self._deferred and self._defer_timer is None: + self._defer_timer = threading.Timer(0.1, self._retry_deferred) + self._defer_timer.daemon = True + self._defer_timer.start() + def _path_str(self, path: str | bytes) -> str: """Decode watchdog paths safely; surrogateescape preserves non-UTF-8 bytes.""" if isinstance(path, str): diff --git a/clipsync/history.py b/clipsync/history.py index 4f2ad05..3322212 100644 --- a/clipsync/history.py +++ b/clipsync/history.py @@ -10,7 +10,9 @@ from __future__ import annotations +import base64 import contextlib +import io import json import logging import os @@ -19,6 +21,8 @@ from dataclasses import dataclass from typing import Any +from PIL import Image + from . import config from .crypto import decrypt, encrypt, is_encrypted @@ -30,9 +34,19 @@ class HistoryEntry: text: str timestamp: float source: str = "local" # 'local' or 'remote' + pinned: bool = False + kind: str = "text" + image_b64: str = "" def to_dict(self) -> dict[str, Any]: - return {"text": self.text, "timestamp": self.timestamp, "source": self.source} + return { + "text": self.text, + "timestamp": self.timestamp, + "source": self.source, + "pinned": self.pinned, + "kind": self.kind, + "image_b64": self.image_b64, + } @classmethod def from_dict(cls, data: dict[str, Any]) -> HistoryEntry: @@ -40,6 +54,9 @@ def from_dict(cls, data: dict[str, Any]) -> HistoryEntry: text=data["text"], timestamp=float(data["timestamp"]), source=str(data.get("source", "local")), + pinned=bool(data.get("pinned", False)), + kind=str(data.get("kind", "text")), + image_b64=str(data.get("image_b64", "")), ) @@ -65,6 +82,8 @@ def __init__(self, settings: config.Settings | None = None) -> None: # Set when the on-disk file holds data we could not read and could not # move aside. Persisting would destroy it, so we stay in memory only. self._readonly: bool = False + self._stop = threading.Event() + self._cleanup_thread: threading.Thread | None = None self._load() def _passphrase(self) -> str: @@ -81,12 +100,16 @@ def _auto_clear_minutes(self) -> int: # user's belief that they expire. A stringified "30" is accepted. return _coerce_int(self._settings.get("history_auto_clear_minutes"), 0) - def _prune_old(self) -> None: + def _prune_old(self) -> bool: minutes = self._auto_clear_minutes() if minutes <= 0: - return + return False cutoff = time.time() - (minutes * 60) + previous = len(self._entries) + # Pins improve retrieval, but do not override the user's privacy + # retention window. Pinned sensitive content expires on the same timer. self._entries = [e for e in self._entries if e.timestamp > cutoff] + return len(self._entries) != previous def _quarantine_unreadable(self, reason: str) -> None: """Move an undecryptable history file aside instead of overwriting it. @@ -158,7 +181,8 @@ def _load(self) -> None: entries.sort(key=lambda e: e.timestamp) with self._lock: self._entries = entries[-self._max_items :] if len(entries) > self._max_items else entries - self._prune_old() + if self._prune_old(): + self._persist_locked() except (KeyError, ValueError) as exc: log.warning("Failed to load clipboard history: %s", exc) @@ -204,8 +228,69 @@ def add_entry(self, text: str, source: str = "local") -> None: self._prune_old() self._persist_locked() + def add_image(self, png_bytes: bytes, source: str = "local") -> None: + """Store a bounded PNG thumbnail, encrypted with the history payload.""" + if not self._enabled or not png_bytes: + return + try: + with Image.open(io.BytesIO(png_bytes)) as image: + image.thumbnail((320, 240)) + output = io.BytesIO() + image.convert("RGBA").save(output, format="PNG", optimize=True) + thumbnail = output.getvalue() + except (OSError, ValueError): + log.warning("Could not create clipboard image history thumbnail") + return + if len(thumbnail) > 512 * 1024: + log.warning("Clipboard image history thumbnail exceeded 512 KiB; skipping") + return + entry = HistoryEntry( + text="", + timestamp=time.time(), + source=source, + kind="image", + image_b64=base64.b64encode(thumbnail).decode("ascii"), + ) + with self._lock: + self._entries.append(entry) + # Keep at most ten thumbnails even if text history allows more. + images = [candidate for candidate in self._entries if candidate.kind == "image"] + for old in images[:-10]: + self._entries.remove(old) + while len(self._entries) > self._max_items: + self._entries.pop(0) + self._prune_old() + self._persist_locked() + + def start(self) -> None: + if self._cleanup_thread is not None and self._cleanup_thread.is_alive(): + return + self._stop.clear() + self._cleanup_thread = threading.Thread(target=self._cleanup_loop, name="clipsync-history", daemon=True) + self._cleanup_thread.start() + + def stop(self) -> None: + self._stop.set() + if self._cleanup_thread is not None and self._cleanup_thread.is_alive(): + self._cleanup_thread.join(timeout=2) + self._cleanup_thread = None + + def _cleanup_loop(self) -> None: + while not self._stop.wait(30): + self.expire_now() + + def expire_now(self) -> bool: + """Remove expired entries from memory and disk immediately.""" + with self._lock: + changed = self._prune_old() + if changed: + self._persist_locked() + return changed + def get_entries(self) -> list[HistoryEntry]: with self._lock: + if self._prune_old(): + self._persist_locked() return list(self._entries) def clear(self) -> None: @@ -230,6 +315,26 @@ def is_enabled(self) -> bool: def set_enabled(self, value: bool) -> None: self._enabled = value + def refresh_settings(self) -> None: + if self._settings is None: + return + with self._lock: + self._enabled = _coerce_bool(self._settings.get("history_enabled", True), True) + self._max_items = _coerce_int(self._settings.get("history_max_items", 50), 50) + while len(self._entries) > self._max_items: + self._entries.pop(0) + self._prune_old() + self._persist_locked() + + def set_pinned(self, timestamp: float, pinned: bool) -> bool: + with self._lock: + for entry in self._entries: + if entry.timestamp == timestamp: + entry.pinned = pinned + self._persist_locked() + return True + return False + def count(self) -> int: with self._lock: return len(self._entries) diff --git a/clipsync/main.py b/clipsync/main.py index 62c52b6..2d69ecd 100644 --- a/clipsync/main.py +++ b/clipsync/main.py @@ -23,9 +23,11 @@ import signal import sys import threading +import time from pathlib import Path import pystray +import requests from PIL import Image, ImageDraw from . import config, update @@ -149,6 +151,9 @@ def __init__(self) -> None: self._quitting = threading.Event() self._pending_lock = threading.Lock() self._pending: dict[str, dict[str, object]] = {} + self._hotkey_listener: object | None = None + self._status_stop = threading.Event() + self._status_thread: threading.Thread | None = None def start(self) -> None: config.configure_logging() @@ -161,8 +166,14 @@ def start(self) -> None: raise RuntimeError("Syncthing started but REST client was not initialized") self.clipboard = ClipboardSync(self.settings) self.clipboard.start() + self._start_status_monitor() - self.file_transfer = FileTransfer(self.settings, on_received=self._on_file_received) + self.file_transfer = FileTransfer( + self.settings, + on_received=self._on_file_received, + expected_receivers=self._expected_file_receivers, + acknowledger_id=self._file_acknowledger_id, + ) self.file_transfer.start() self.log_mirror = LogMirror(self.settings) @@ -176,6 +187,7 @@ def start(self) -> None: auto_accept=lambda: bool(self.settings.get("auto_accept_incoming")), ) self.watcher.start() + self._start_history_hotkey() if not self.settings.get("first_run_completed"): self.settings.set("first_run_completed", True) @@ -208,6 +220,8 @@ def _start_syncthing_with_retry(self) -> None: def _run_tray(self) -> None: image = _load_or_create_icon() menu = pystray.Menu( + pystray.MenuItem(self._sync_status_title, None, enabled=False), + pystray.Menu.SEPARATOR, pystray.MenuItem( "Open ClipSync", lambda _i, _it: self.ui.open("tabbed:devices"), @@ -219,7 +233,16 @@ def _run_tray(self) -> None: visible=lambda _item: self._pending_count() > 0, ), pystray.MenuItem("Clipboard History", lambda _i, _it: self.ui.open("history")), + pystray.MenuItem( + "Retry Incoming Clipboard Now", + self._menu_retry_incoming, + visible=lambda _item: ( + self.clipboard is not None and bool(self.clipboard.status_snapshot()["incoming_pending"]) + ), + ), + pystray.MenuItem("Send Clipboard Now", self._menu_send_clipboard), pystray.MenuItem("Send File…", lambda _i, _it: self.ui.open("file_picker")), + pystray.MenuItem(self._transfer_status_title, None, enabled=False), pystray.MenuItem("Add Device", lambda _i, _it: self.ui.open("tabbed:pair")), pystray.MenuItem("Connected Devices", lambda _i, _it: self.ui.open("tabbed:devices")), pystray.MenuItem( @@ -227,6 +250,7 @@ def _run_tray(self) -> None: self._menu_toggle_pause, checked=lambda _item: bool(self.settings.get("sync_paused")), ), + pystray.MenuItem("Pause for 15 minutes", self._menu_pause_temporarily), pystray.Menu.SEPARATOR, pystray.MenuItem("Settings", lambda _i, _it: self.ui.open("tabbed:settings")), pystray.MenuItem("Check for Updates…", self._menu_check_updates), @@ -250,7 +274,10 @@ def _on_tray_ready(self, icon: pystray.Icon) -> None: icon.visible = True if self._pending_first_run_notice: self._pending_first_run_notice = False - self._notify(f"{config.APP_NAME} is running", "Click the tray icon to add a device.") + self.ui.open("tabbed:pair") + self._notify( + f"{config.APP_NAME} is running", "Pair both devices, approve the request, then copy a test phrase." + ) def _notify(self, title: str, message: str) -> None: if not self.settings.get("show_notifications", True) and title != f"{config.APP_NAME} is running": @@ -265,11 +292,86 @@ def _notify(self, title: str, message: str) -> None: log.debug("Tray notification not supported on this platform") log.info("%s: %s", title, message) + def _sync_status_title(self, _item: pystray.MenuItem) -> str: + if self.clipboard is None: + return "Status: starting" + return self.clipboard.status_text() + + def _transfer_status_title(self, _item: pystray.MenuItem) -> str: + if self.file_transfer is None: + return "Transfers: starting" + return self.file_transfer.status_text() + + def _start_status_monitor(self) -> None: + self._status_stop.clear() + self._status_thread = threading.Thread(target=self._status_loop, name="clipsync-status", daemon=True) + self._status_thread.start() + + def _status_loop(self) -> None: + next_transfer_cleanup = time.monotonic() + 3600 + while not self._status_stop.is_set(): + state = "Syncthing unavailable" + try: + client = self.syncthing.client + if client is not None and client.ping(timeout=2): + folder = client.get_folder_status() + devices = client.connected_devices() + locally_current = folder.get("state") == "idle" and int(folder.get("needTotalItems", 0)) == 0 + if not devices: + state = "No paired devices" + elif any(not device.get("connected") for device in devices): + state = "Syncthing waiting for an offline device" + elif locally_current and all( + isinstance(device.get("completion"), (int, float)) and float(device["completion"]) >= 99.99 + for device in devices + ): + state = "Syncthing replicated to paired devices" + else: + state = "Syncthing syncing" + except (OSError, TypeError, ValueError, requests.RequestException): + state = "Syncthing unavailable" + if self.clipboard is not None: + self.clipboard.set_replication_status(state) + if self.file_transfer is not None and time.monotonic() >= next_transfer_cleanup: + self.file_transfer.cleanup_delivered() + next_transfer_cleanup = time.monotonic() + 3600 + if self.tray is not None: + try: + self.tray.update_menu() + except Exception: + log.debug("Tray status refresh failed", exc_info=True) + self._status_stop.wait(10) + def _menu_toggle_pause(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> None: paused = not bool(self.settings.get("sync_paused")) self.settings.set("sync_paused", paused) self._on_pause_changed(paused) + def _menu_pause_temporarily(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> None: + self.settings.set("paused_until", time.time() + 15 * 60) + self._notify(config.APP_NAME, "Clipboard sync paused for 15 minutes.") + + def _menu_send_clipboard(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> None: + if self.clipboard is not None: + threading.Thread(target=self.clipboard.send_current, daemon=True).start() + + def _menu_retry_incoming(self, _icon: pystray.Icon, _item: pystray.MenuItem) -> None: + if self.clipboard is not None: + threading.Thread(target=self.clipboard.retry_incoming_now, daemon=True).start() + + def _start_history_hotkey(self) -> None: + shortcut = str(self.settings.get("history_shortcut") or "").strip() + if not shortcut: + return + try: + from pynput import keyboard + + listener = keyboard.GlobalHotKeys({shortcut: lambda: self.ui.open("history")}) + listener.start() + self._hotkey_listener = listener + except Exception as exc: + log.info("Global history shortcut unavailable: %s", exc) + def _menu_quit(self, icon: pystray.Icon, _item: pystray.MenuItem) -> None: log.info("Quit requested from tray") icon.stop() @@ -381,6 +483,15 @@ def _handle_ui_event(self, evt: dict) -> None: if self.clipboard is not None: self.clipboard.clear_history() log.info("Clipboard history cleared from UI") + elif kind == "pin_history": + timestamp = evt.get("timestamp") + if self.clipboard is not None and isinstance(timestamp, (int, float)): + self.clipboard.set_history_pinned(float(timestamp), bool(evt.get("pinned"))) + elif kind == "settings_changed": + if self.clipboard is not None: + self.clipboard.refresh_settings() + if self.tray is not None: + self.tray.update_menu() elif kind == "reset": log.info("Devices reset from UI") elif kind == "accept_device": @@ -412,9 +523,14 @@ def _on_pause_changed(self, paused: bool) -> None: f"{config.APP_NAME} {'paused' if paused else 'resumed'}", "Clipboard sync is off." if paused else "Clipboard sync is on.", ) + if not paused and self.clipboard is not None: + threading.Thread(target=self.clipboard.reconcile_latest, daemon=True).start() def _on_device_accepted(self, device_id: str) -> None: - self._notify("Device connected", f"Now syncing clipboard with {device_id[:7]}") + self._notify( + "Device connected", + f"Now syncing with {device_id[:7]}. Copy a short test phrase and check the tray status.", + ) def _on_folder_changed(self, new_path: str) -> None: """Repoint every consumer of the sync folder, not just the clipboard. @@ -451,7 +567,12 @@ def _on_folder_changed(self, new_path: str) -> None: self.clipboard = ClipboardSync(self.settings) self.clipboard.start() - self.file_transfer = FileTransfer(self.settings, on_received=self._on_file_received) + self.file_transfer = FileTransfer( + self.settings, + on_received=self._on_file_received, + expected_receivers=self._expected_file_receivers, + acknowledger_id=self._file_acknowledger_id, + ) self.file_transfer.start() log.info("Sync folder changed to %s; Syncthing, clipboard and file transfer restarted", new_path) @@ -459,13 +580,28 @@ def _send_file_worker(self, source: Path) -> None: if self.file_transfer is None: return try: - dest = self.file_transfer.send(source) - self._notify("File sent", f"{source.name} ({dest.stat().st_size // 1024} KB)") + self.file_transfer.send(source) + self._notify("File published", f"{source.name} is awaiting a receiver acknowledgement.") except Exception as exc: log.exception("Failed to send file %s", source) self._notify("File send failed", str(exc)) - def _on_file_received(self, path: Path, sender: str) -> None: + def _expected_file_receivers(self) -> int: + client = self.syncthing.client + if client is None: + return 0 + try: + return len(client.connected_devices()) + except (OSError, TypeError, ValueError, requests.RequestException): + return 0 + + def _file_acknowledger_id(self) -> str: + client = self.syncthing.client + if client is None: + return "" + return client.get_device_id() + + def _on_file_received(self, path: Path, sender: str) -> bool: downloads = Path.home() / "Downloads" downloads.mkdir(parents=True, exist_ok=True) @@ -476,6 +612,9 @@ def _on_file_received(self, path: Path, sender: str) -> None: display_name = path.name if encrypted and display_name.endswith(ENCRYPTED_SUFFIX): display_name = display_name[: -len(ENCRYPTED_SUFFIX)] + name_parts = display_name.split("_", 3) + if len(name_parts) == 4 and len(name_parts[0]) == 32: + display_name = name_parts[3] passphrase = self.settings.get("encryption_passphrase") or "" if encrypted and not isinstance(passphrase, str): passphrase = "" @@ -485,7 +624,7 @@ def _on_file_received(self, path: Path, sender: str) -> None: f"File from {sender}", f"{display_name} is encrypted and no passphrase is set; not saved.", ) - return + return False stem = Path(display_name).stem suffix = Path(display_name).suffix @@ -505,7 +644,7 @@ def _on_file_received(self, path: Path, sender: str) -> None: dest = downloads / f"{stem}_{attempt}{suffix}" if fd < 0: log.warning("Could not find free filename for received file %s", path.name) - return + return False try: if encrypted: # decrypt_file owns the destination, so release the claim we @@ -521,7 +660,7 @@ def _on_file_received(self, path: Path, sender: str) -> None: f"File from {sender}", f"{display_name} could not be decrypted ({exc}).", ) - return + return False config.set_file_permissions(dest) else: with os.fdopen(fd, "wb") as out, path.open("rb") as src: @@ -533,13 +672,19 @@ def _on_file_received(self, path: Path, sender: str) -> None: pass except OSError: log.exception("Failed to save received file %s", path) + if fd >= 0: + try: + os.close(fd) + except OSError: + pass try: dest.unlink(missing_ok=True) except OSError: pass - return + return False log.info("Saved received file to %s", dest) self._notify(f"File from {sender}", f"Saved to ~/Downloads/{dest.name}") + return True # Shutdown --------------------------------------------------------------- @@ -554,6 +699,16 @@ def _shutdown(self) -> None: log.exception("Error closing UI subprocesses") if self.watcher is not None: self.watcher.stop() + self._status_stop.set() + if self._status_thread is not None and self._status_thread.is_alive(): + self._status_thread.join(timeout=3) + self._status_thread = None + if self._hotkey_listener is not None: + try: + self._hotkey_listener.stop() # type: ignore[attr-defined] + except Exception: + log.debug("Could not stop history hotkey listener", exc_info=True) + self._hotkey_listener = None if self.log_mirror is not None: self.log_mirror.stop() if self.file_transfer is not None: diff --git a/clipsync/syncthing.py b/clipsync/syncthing.py index 6f521d5..1185d3b 100644 --- a/clipsync/syncthing.py +++ b/clipsync/syncthing.py @@ -44,12 +44,18 @@ _STARTUP_PING_TIMEOUT = 3 # short per-attempt timeout during startup probing _STARTUP_WAIT = 60 # initial scan on Windows with slow storage can exceed 30s _RESTART_DELAY = 10 +_RESTART_DELAY_MAX = 300 class SyncthingError(RuntimeError): """Raised for any unrecoverable Syncthing failure surfaced to callers.""" +def _next_restart_delay(current: int) -> int: + """Return the next bounded delay after an unsuccessful restart.""" + return min(current * 2, _RESTART_DELAY_MAX) + + def _platform_archive_info() -> tuple[str, str, str]: """Return (os_name, arch, extension) for the syncthing release asset.""" system = platform.system() @@ -860,6 +866,12 @@ def get_discovered_devices(self) -> dict[str, list[str]]: def get_connections(self) -> dict[str, Any]: return self._get("/rest/system/connections") or {} + def get_folder_status(self, folder_id: str = config.CLIPBOARD_FOLDER_ID) -> dict[str, Any]: + return self._get(f"/rest/db/status?folder={folder_id}") or {} + + def get_device_completion(self, device_id: str, folder_id: str = config.CLIPBOARD_FOLDER_ID) -> dict[str, Any]: + return self._get(f"/rest/db/completion?device={device_id}&folder={folder_id}") or {} + def add_device(self, device_id: str, name: str = "") -> None: """Idempotently add a remote device to our config.""" devices = self.get_devices() @@ -928,12 +940,19 @@ def connected_devices(self) -> list[dict[str, Any]]: if not did or did == my_id: continue conn = connections.get(did) or {} + completion: float | None = None + if conn.get("connected"): + try: + completion = float(self.get_device_completion(did).get("completion", 0.0)) + except (requests.RequestException, TypeError, ValueError): + completion = None out.append( { "deviceID": did, "name": d.get("name") or did[:7], "connected": bool(conn.get("connected")), "address": conn.get("address", ""), + "completion": completion, } ) return out @@ -1049,6 +1068,7 @@ def _pump_output(self, proc: subprocess.Popen) -> None: pass def _watch(self) -> None: + restart_delay = _RESTART_DELAY while not self._stop.is_set(): with self._lock: proc = self._proc @@ -1060,15 +1080,17 @@ def _watch(self) -> None: continue if self._stop.is_set(): break - log.error("Syncthing exited with code %s, restarting in %ss", rc, _RESTART_DELAY) - if self._stop.wait(_RESTART_DELAY): + log.error("Syncthing exited with code %s, restarting in %ss", rc, restart_delay) + if self._stop.wait(restart_delay): break try: self._spawn() - if self.client is not None: - self.client.wait_until_ready() + if self.client is not None and not self.client.wait_until_ready(): + raise SyncthingError("Syncthing did not become ready after restart") + restart_delay = _RESTART_DELAY except Exception: log.exception("Failed to restart Syncthing") + restart_delay = _next_restart_delay(restart_delay) def stop(self) -> None: self._stop.set() diff --git a/clipsync/ui.py b/clipsync/ui.py index b9a0511..61bf4e7 100644 --- a/clipsync/ui.py +++ b/clipsync/ui.py @@ -372,6 +372,9 @@ def on_accept_device(self, device_id: str) -> None: def on_reject_device(self, device_id: str) -> None: _emit("reject_device", device_id=device_id) + def on_pin_history(self, timestamp: float, pinned: bool) -> None: + _emit("pin_history", timestamp=timestamp, pinned=pinned) + # --------------------------------------------------------------------------- # Windows @@ -451,8 +454,16 @@ def __init__( self._scan_container = container ctk.CTkLabel(container, text="Pair a device", font=_fonts()["headline"], text_color=THEME.text).pack( - pady=(0, 18) + pady=(0, 6) ) + ctk.CTkLabel( + container, + text="1. Open ClipSync on both devices. 2. Add or scan the other device. 3. Approve the request there. 4. Copy a short test phrase and check the tray status.", + font=_fonts()["small"], + text_color=THEME.muted, + wraplength=430, + justify="left", + ).pack(fill="x", pady=(0, 18)) _section_header(container, "Nearby devices").pack(fill="x") self._nearby_frame = ctk.CTkScrollableFrame( @@ -850,7 +861,11 @@ def _build_row(self, device: dict) -> None: ).grid(row=1, column=0, sticky="we", padx=12, pady=(0, 10)) status_color = config.COLOR_SUCCESS if device["connected"] else THEME.muted - status_text = "● Connected" if device["connected"] else "○ Offline" + completion = device.get("completion") + if device["connected"] and isinstance(completion, (int, float)): + status_text = f"● Connected · {completion:.0f}% replicated" + else: + status_text = "● Connected" if device["connected"] else "○ Offline" ctk.CTkLabel(row, text=status_text, text_color=status_color, font=_fonts()["small"]).grid( row=0, column=1, rowspan=2, padx=12 ) @@ -974,6 +989,14 @@ def __init__( anchor="w", padx=16, pady=4 ) + self._manual_send_var = ctk.BooleanVar(value=bool(app.settings.get("manual_send"))) + _switch( + general_card, + "Manual send (use tray → Send Clipboard Now)", + self._manual_send_var, + self._on_manual_send_toggle, + ).pack(anchor="w", padx=16, pady=4) + self._auto_accept_var = ctk.BooleanVar(value=bool(app.settings.get("auto_accept_incoming"))) _switch( general_card, @@ -981,6 +1004,14 @@ def __init__( self._auto_accept_var, self._on_auto_accept_toggle, ).pack(anchor="w", padx=16, pady=(4, 14)) + ctk.CTkLabel( + general_card, + text="Only enable this on trusted private networks. New pairing requests gain clipboard access.", + font=_fonts()["tiny"], + justify="left", + wraplength=340, + text_color=THEME.muted, + ).pack(anchor="w", padx=(52, 16), pady=(0, 14)) privacy_card = _card_frame(container) privacy_card.pack(fill="x", pady=(0, 16)) @@ -1001,6 +1032,25 @@ def __init__( text_color=THEME.muted, ).pack(anchor="w", padx=(52, 16), pady=(0, 14)) + self._secret_filter_var = ctk.BooleanVar(value=bool(app.settings.get("filter_likely_secrets"))) + _switch( + privacy_card, + "Block likely secrets from text sync", + self._secret_filter_var, + self._on_secret_filter_toggle, + ).pack(anchor="w", padx=16, pady=(0, 2)) + ctk.CTkLabel( + privacy_card, + text=( + "Checks incoming and outgoing text for common private-key, API-token, and password patterns. " + "This heuristic can miss secrets or flag examples; images are not checked." + ), + font=_fonts()["tiny"], + justify="left", + wraplength=340, + text_color=THEME.muted, + ).pack(anchor="w", padx=(52, 16), pady=(0, 14)) + _section_header(privacy_card, "Encryption passphrase (optional)").pack(anchor="w", padx=16, pady=(4, 2)) ctk.CTkLabel( privacy_card, @@ -1011,7 +1061,7 @@ def __init__( passphrase_row = ctk.CTkFrame(privacy_card, fg_color="transparent") passphrase_row.pack(fill="x", padx=16, pady=(6, 16)) self._passphrase_entry = _entry(passphrase_row, show="•") - self._passphrase_entry.insert(0, str(app.settings.get("encryption_passphrase") or "")) + self._passphrase_entry.insert(0, app.settings.get_passphrase()) self._passphrase_entry.pack(side="left", fill="x", expand=True, padx=(0, 8)) _primary_button(passphrase_row, text="Save", command=self._on_save_passphrase, width=70).pack(side="left") @@ -1041,7 +1091,23 @@ def __init__( history_card = _card_frame(container) history_card.pack(fill="x", pady=(0, 16)) - _section_header(history_card, "Clipboard history auto-clear").pack(anchor="w", padx=16, pady=(14, 8)) + _section_header(history_card, "Clipboard history").pack(anchor="w", padx=16, pady=(14, 8)) + self._history_enabled_var = ctk.BooleanVar(value=bool(app.settings.get("history_enabled", True))) + _switch( + history_card, + "Save clipboard history on this device", + self._history_enabled_var, + self._on_history_enabled_toggle, + ).pack(anchor="w", padx=16, pady=(0, 2)) + ctk.CTkLabel( + history_card, + text="Turn off to stop saving new clips. Existing entries remain until cleared or expired below.", + font=_fonts()["tiny"], + justify="left", + wraplength=340, + text_color=THEME.muted, + ).pack(anchor="w", padx=(52, 16), pady=(0, 10)) + _section_header(history_card, "Auto-clear saved history").pack(anchor="w", padx=16, pady=(4, 8)) self._auto_clear_options: dict[str, int] = { "Never": 0, "5 minutes": 5, @@ -1071,6 +1137,52 @@ def __init__( self._auto_clear_menu.pack(side="left") self._auto_clear_menu.set(auto_clear_label) + _section_header(history_card, "Maximum clipboard item size").pack(anchor="w", padx=16, pady=(4, 8)) + self._text_size_options = { + "256 KiB text": 256 * 1024, + "1 MiB text": 1024 * 1024, + "5 MiB text": 5 * 1024 * 1024, + } + self._image_size_options = { + "1 MiB images": 1024 * 1024, + "5 MiB images": 5 * 1024 * 1024, + "20 MiB images": 20 * 1024 * 1024, + } + size_row = ctk.CTkFrame(history_card, fg_color="transparent") + size_row.pack(fill="x", padx=16, pady=(0, 14)) + text_menu = ctk.CTkOptionMenu( + size_row, + values=list(self._text_size_options), + command=self._on_text_size_changed, + ) + text_menu.pack(side="left", padx=(0, 8)) + text_menu.set( + next( + ( + label + for label, size in self._text_size_options.items() + if size == app.settings.get("max_text_bytes") + ), + "1 MiB text", + ) + ) + image_menu = ctk.CTkOptionMenu( + size_row, + values=list(self._image_size_options), + command=self._on_image_size_changed, + ) + image_menu.pack(side="left") + image_menu.set( + next( + ( + label + for label, size in self._image_size_options.items() + if size == app.settings.get("max_image_bytes") + ), + "5 MiB images", + ) + ) + advanced_card = _card_frame(container) advanced_card.pack(fill="x", pady=(0, 16)) _section_header(advanced_card, "Advanced").pack(anchor="w", padx=16, pady=(14, 8)) @@ -1145,6 +1257,12 @@ def _on_sync_enabled_toggle(self) -> None: self._app.on_pause_changed(paused) self._status.configure(text=f"Sync {'enabled' if enabled else 'paused'}.") + def _on_manual_send_toggle(self) -> None: + enabled = bool(self._manual_send_var.get()) + self._app.settings.set("manual_send", enabled) + self._app.on_settings_changed() + self._status.configure(text="Manual send enabled." if enabled else "Automatic clipboard sync enabled.") + def _on_log_mirror_toggle(self) -> None: enabled = bool(self._log_mirror_var.get()) self._app.settings.set("debug_log_mirror", enabled) @@ -1157,8 +1275,27 @@ def _on_log_mirror_toggle(self) -> None: ) ) + def _on_secret_filter_toggle(self) -> None: + enabled = bool(self._secret_filter_var.get()) + self._app.settings.set("filter_likely_secrets", enabled) + self._app.on_settings_changed() + self._status.configure( + text=("Likely-secret text filter enabled." if enabled else "Likely-secret text filter disabled.") + ) + + def _on_history_enabled_toggle(self) -> None: + enabled = bool(self._history_enabled_var.get()) + self._app.settings.set("history_enabled", enabled) + self._app.on_settings_changed() + self._status.configure( + text=("Clipboard history enabled." if enabled else "Clipboard history disabled; saved entries remain.") + ) + def _on_auto_accept_toggle(self) -> None: enabled = bool(self._auto_accept_var.get()) + if enabled: + self._confirm_auto_accept() + return self._app.settings.set("auto_accept_incoming", enabled) self._app.on_settings_changed() self._status.configure( @@ -1169,6 +1306,48 @@ def _on_auto_accept_toggle(self) -> None: ) ) + def _confirm_auto_accept(self) -> None: + confirm = ctk.CTkToplevel(self._win) + confirm.title("Enable auto-accept?") + confirm.configure(fg_color=THEME.bg) + confirm.resizable(False, False) + _center_window(confirm, 360, 205) + confirm.bind("", lambda _e: cancel()) + container = ctk.CTkFrame(confirm, fg_color="transparent") + container.pack(fill="both", expand=True, padx=24, pady=24) + ctk.CTkLabel(container, text="Auto-accept pairing?", font=_fonts()["title"], text_color=THEME.text).pack( + anchor="w" + ) + ctk.CTkLabel( + container, + text="Any device that can send a pairing request will be trusted immediately and can receive your clipboard. Use only on a private network.", + font=_fonts()["small"], + justify="left", + wraplength=310, + text_color=THEME.muted, + ).pack(anchor="w", pady=(6, 18)) + buttons = ctk.CTkFrame(container, fg_color="transparent") + buttons.pack(fill="x") + + def cancel() -> None: + self._auto_accept_var.set(False) + confirm.destroy() + + def enable() -> None: + self._app.settings.set("auto_accept_incoming", True) + self._app.on_settings_changed() + self._status.configure(text="Auto-accept enabled. New requests will pair immediately.") + confirm.destroy() + + _secondary_button(buttons, text="Cancel", command=cancel).pack(side="left", expand=True, fill="x", padx=(0, 6)) + _primary_button( + buttons, + text="Enable", + fg_color=config.COLOR_DANGER, + hover_color=config.COLOR_DANGER_HOVER, + command=enable, + ).pack(side="left", expand=True, fill="x", padx=(6, 0)) + def _on_theme_changed(self, value: str) -> None: self._app.settings.set("theme", value) self._app.on_settings_changed() @@ -1183,9 +1362,23 @@ def _on_auto_clear_changed(self, value: str) -> None: text=(f"History auto-clear set to {value}." if minutes > 0 else "History auto-clear disabled.") ) + def _on_text_size_changed(self, value: str) -> None: + self._app.settings.set("max_text_bytes", self._text_size_options[value]) + self._app.on_settings_changed() + self._status.configure(text=f"Text limit set to {value}.") + + def _on_image_size_changed(self, value: str) -> None: + self._app.settings.set("max_image_bytes", self._image_size_options[value]) + self._app.on_settings_changed() + self._status.configure(text=f"Image limit set to {value}.") + def _on_save_passphrase(self) -> None: new_value = self._passphrase_entry.get() - self._app.settings.set("encryption_passphrase", new_value) + try: + self._app.settings.set_passphrase(new_value) + except config.SecretStorageError: + self._status.configure(text="Could not access the OS keychain. Passphrase was not changed.") + return self._app.on_settings_changed() if new_value: self._status.configure(text="Encryption enabled. Set the same passphrase on every device.") @@ -1643,26 +1836,45 @@ def __init__(self, parent: ctk.CTk, app: AppContext, on_close: Callable[[], None btn_row = ctk.CTkFrame(container, fg_color="transparent") btn_row.pack(fill="x", pady=(14, 0)) - _primary_button(btn_row, text="Refresh", command=self._refresh, width=100).pack(side="left") + _primary_button(btn_row, text="Refresh", command=self._reload_history, width=100).pack(side="left") _secondary_button(btn_row, text="Clear All", command=self._confirm_clear, width=100).pack(side="right") - self._all_entries: list[object] = [] + self._all_entries: list[Any] = [] + self._visible_entries: list[Any] = [] + self._row_widgets: list[ctk.CTkFrame] = [] + self._image_refs: list[ctk.CTkImage] = [] + self._selected_index = 0 + self.window.bind("", lambda _event: self._search_entry.focus_set()) + self.window.bind("", lambda _event: self._move_selection(1)) + self.window.bind("", lambda _event: self._move_selection(-1)) + self.window.bind("", lambda _event: self._copy_selected()) + self._reload_history() + + def _reload_history(self) -> None: + from .history import ClipboardHistory + + entries = ClipboardHistory(self._app.settings).get_entries() + self._all_entries = sorted(entries, key=lambda entry: (not getattr(entry, "pinned", False), -entry.timestamp)) self._refresh() def _refresh(self) -> None: - from .history import ClipboardHistory - for w in self._list_frame.winfo_children(): w.destroy() - - history = ClipboardHistory(self._app.settings) - self._all_entries = list(reversed(history.get_entries())) + self._row_widgets = [] + self._image_refs = [] query = self._search_var.get().strip().lower() if query: - entries = [e for e in self._all_entries if query in getattr(e, "text", "").lower()] + entries = [ + e + for e in self._all_entries + if query in getattr(e, "text", "").lower() + or (getattr(e, "kind", "text") == "image" and query in "image") + ] else: entries = self._all_entries + self._visible_entries = entries + self._selected_index = min(self._selected_index, max(0, len(entries) - 1)) if not entries: empty = ctk.CTkFrame(self._list_frame, fg_color="transparent") @@ -1702,14 +1914,17 @@ def _refresh(self) -> None: now = _time.time() for entry in entries: - self._build_row(entry, now) + self._row_widgets.append(self._build_row(entry, now)) + self._show_selection() - def _build_row(self, entry: object, now: float) -> None: + def _build_row(self, entry: Any, now: float) -> ctk.CTkFrame: from datetime import datetime ts = getattr(entry, "timestamp", 0.0) text = getattr(entry, "text", "") source = getattr(entry, "source", "local") + pinned = bool(getattr(entry, "pinned", False)) + kind = getattr(entry, "kind", "text") dt = datetime.fromtimestamp(ts) age = now - ts @@ -1733,16 +1948,26 @@ def _build_row(self, entry: object, now: float) -> None: source_color = config.COLOR_PRIMARY if source == "remote" else THEME.muted meta = ctk.CTkLabel( row, - text=f"{time_str} • {source_label}", + text=f"{time_str} • {source_label}{' • Pinned' if pinned else ''}", font=_fonts()["tiny"], text_color=source_color, anchor="w", ) meta.grid(row=0, column=0, columnspan=2, sticky="ew", padx=12, pady=(8, 0)) + image_b64 = str(getattr(entry, "image_b64", "")) + preview_image: ctk.CTkImage | None = None + if kind == "image" and image_b64: + try: + image = Image.open(io.BytesIO(base64.b64decode(image_b64))) + preview_image = ctk.CTkImage(light_image=image, dark_image=image, size=image.size) + self._image_refs.append(preview_image) + except (OSError, ValueError): + preview_image = None preview_label = ctk.CTkLabel( row, - text=preview or "(empty)", + text="" if preview_image is not None else (preview or "Image" if kind == "image" else "(empty)"), + image=preview_image, font=_fonts()["small"], text_color=THEME.text, anchor="w", @@ -1750,8 +1975,8 @@ def _build_row(self, entry: object, now: float) -> None: ) preview_label.grid(row=1, column=0, columnspan=2, sticky="ew", padx=12, pady=(2, 8)) - def _copy_handler(t: str = text) -> None: - self._copy_entry(t) + def _copy_handler(e: Any = entry) -> None: + self._copy_entry(e) copy_btn = _primary_button( row, @@ -1761,11 +1986,51 @@ def _copy_handler(t: str = text) -> None: ) copy_btn.grid(row=0, column=2, rowspan=2, padx=(0, 10), pady=6) - def _copy_entry(self, text: str) -> None: + def _pin_handler(e: Any = entry) -> None: + self._toggle_pin(e) + + _secondary_button( + row, + text="Unpin" if pinned else "Pin", + width=52, + command=_pin_handler, + ).grid(row=0, column=3, rowspan=2, padx=(0, 10), pady=6) + return row + + def _toggle_pin(self, entry: Any) -> None: + pinned = not bool(getattr(entry, "pinned", False)) + entry.pinned = pinned + self._app.on_pin_history(float(getattr(entry, "timestamp", 0.0)), pinned) + self._all_entries.sort(key=lambda candidate: (not getattr(candidate, "pinned", False), -candidate.timestamp)) + self._refresh() + + def _move_selection(self, delta: int) -> str: + if self._visible_entries: + self._selected_index = (self._selected_index + delta) % len(self._visible_entries) + self._show_selection() + return "break" + + def _show_selection(self) -> None: + for index, row in enumerate(self._row_widgets): + row.configure(border_color=config.COLOR_PRIMARY if index == self._selected_index else THEME.border) + + def _copy_selected(self) -> str: + if self._visible_entries: + self._copy_entry(self._visible_entries[self._selected_index]) + return "break" + + def _copy_entry(self, entry: Any) -> None: try: - import pyperclip + if getattr(entry, "kind", "text") == "image": + from .clipboard import _write_image_to_system_clipboard - pyperclip.copy(text) + payload = base64.b64decode(str(getattr(entry, "image_b64", ""))) + if not _write_image_to_system_clipboard(payload): + raise RuntimeError("image clipboard is unavailable") + else: + import pyperclip + + pyperclip.copy(str(getattr(entry, "text", ""))) self._status.configure(text="Copied!") self.window.after(1500, lambda: self._status.configure(text="")) except Exception as exc: @@ -1802,9 +2067,7 @@ def _confirm_clear(self) -> None: def do_clear() -> None: _emit("clear_history") - from .history import ClipboardHistory - - ClipboardHistory(self._app.settings).clear() + self._all_entries = [] dialog.destroy() self._refresh() diff --git a/pyproject.toml b/pyproject.toml index a056ce6..25f9cce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,8 @@ dependencies = [ "requests>=2.31.0", "opencv-python>=4.9.0.80", "cryptography>=41.0.0", + "keyring>=25.0.0", + "pynput>=1.7.7", "pyobjc-core>=10.0; sys_platform == 'darwin'", "pyobjc-framework-Cocoa>=10.0; sys_platform == 'darwin'", ] @@ -75,3 +77,9 @@ warn_unused_ignores = false warn_redundant_casts = true no_implicit_optional = true check_untyped_defs = true + +[tool.pytest.ini_options] +addopts = "-m 'not integration'" +markers = [ + "integration: requires a real desktop clipboard and is excluded from the default test suite", +] diff --git a/requirements.txt b/requirements.txt index f853515..87e9ae3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ opencv-python>=4.9.0.80 # (PEP 695 `type` statement) that mypy can't parse under python_version=3.11. numpy<2.5 cryptography>=41.0.0 -keyring>=25.0 +keyring>=25.0.0 +pynput>=1.7.7 pyobjc-core>=10.0; sys_platform == "darwin" pyobjc-framework-Cocoa>=10.0; sys_platform == "darwin" diff --git a/tests/conftest.py b/tests/conftest.py index 22f5d2b..681879f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,7 +23,23 @@ import pytest -from clipsync import config +from clipsync import config, secure_settings + + +class _MemoryKeyring: + """In-memory keychain so tests never read or alter user credentials.""" + + def __init__(self) -> None: + self.values: dict[tuple[str, str], str] = {} + + def get_password(self, service: str, account: str) -> str | None: + return self.values.get((service, account)) + + def set_password(self, service: str, account: str, value: str) -> None: + self.values[(service, account)] = value + + def delete_password(self, service: str, account: str) -> None: + self.values.pop((service, account), None) @pytest.fixture(autouse=True) @@ -40,10 +56,20 @@ def _isolate_user_data(tmp_path, monkeypatch): for name, filename in ( ("SETTINGS_FILE", "settings.json"), ("LOG_FILE", "clipsync.log"), + ("TRANSFER_STATE_FILE", "transfers.json"), ): if hasattr(config, name): monkeypatch.setattr(config, name, data_dir / filename, raising=False) for name in ("APP_DATA_DIR", "SYNC_FOLDER"): if hasattr(config, name): monkeypatch.setattr(config, name, data_dir / name.lower(), raising=False) + keyring = _MemoryKeyring() + monkeypatch.setattr(config, "_keyring_backend", lambda: keyring) + import keyring as keyring_module + + monkeypatch.setattr(keyring_module, "get_password", keyring.get_password) + monkeypatch.setattr(keyring_module, "set_password", keyring.set_password) + monkeypatch.setattr(keyring_module, "delete_password", keyring.delete_password) + monkeypatch.setattr(secure_settings, "_FALLBACK_FILE", data_dir / "passphrase.enc") + monkeypatch.setattr(secure_settings, "_FALLBACK_SALT_FILE", data_dir / ".salt") return data_dir diff --git a/tests/test_audit_fixes.py b/tests/test_audit_fixes.py index 12b2df9..067618b 100644 --- a/tests/test_audit_fixes.py +++ b/tests/test_audit_fixes.py @@ -379,7 +379,7 @@ def start(self): app._on_file_received = lambda *_a: None monkeypatch.setattr(main_mod, "ClipboardSync", lambda _s: _Stub("clipboard")) - monkeypatch.setattr(main_mod, "FileTransfer", lambda _s, on_received=None: _Stub("file_transfer")) + monkeypatch.setattr(main_mod, "FileTransfer", lambda _s, **_kwargs: _Stub("file_transfer")) new_folder = tmp_path / "new_sync" app._on_folder_changed(str(new_folder)) @@ -471,6 +471,7 @@ def test_file_receive_seen_set_is_bounded(tmp_path, monkeypatch) -> None: handler = ft_mod._FileReceiveHandler(on_received=lambda p, _s: received.append(p)) # Match everything; sender extraction still runs. handler._seen_max = 50 + monkeypatch.setattr(handler, "_within_rate_limit", lambda: True) for i in range(200): incoming = tmp_path / "files" / "peer" / f"file_{i}.txt" diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..ca974ba --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,9 @@ +"""Packaging regressions that only appear in frozen builds.""" + +from clipsync import build + + +def test_build_collects_lazily_imported_keyring_backends() -> None: + args = build._common_args("ClipSync") + index = args.index("--collect-all") + assert args[index + 1] == "keyring" diff --git a/tests/test_clipboard_reliability.py b/tests/test_clipboard_reliability.py new file mode 100644 index 0000000..077b01c --- /dev/null +++ b/tests/test_clipboard_reliability.py @@ -0,0 +1,177 @@ +"""Ordering and retry regressions for the shared native clipboard.""" + +from __future__ import annotations + +import os +import threading +import time + +import pytest + +from clipsync import config +from clipsync.clipboard import ClipboardSync, _ClipboardFileHandler + +PNG_A = b"\x89PNG\r\n\x1a\n" + b"a" +PNG_B = b"\x89PNG\r\n\x1a\n" + b"b" + + +def _sync(tmp_path) -> ClipboardSync: + folder = tmp_path / "sync" + folder.mkdir() + settings = config.Settings(tmp_path / "settings.json") + settings.set("sync_folder", str(folder)) + return ClipboardSync(settings) + + +def test_failed_incoming_write_retries_and_commits_only_on_success(tmp_path, monkeypatch): + sync = _sync(tmp_path) + outcomes = iter((False, True)) + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: next(outcomes)) + + sync._stage_incoming("remote", "text") + assert sync._last_synced is None + assert sync._pending_incoming is not None + + sync._pending_incoming.next_attempt = 0 + sync._attempt_pending_incoming() + assert sync._last_synced == "remote" + assert sync._pending_incoming is None + + +def test_incoming_survives_prolonged_failure_and_eventually_applies(tmp_path, monkeypatch): + sync = _sync(tmp_path) + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: False) + monkeypatch.setattr("clipsync.clipboard.time.monotonic", lambda: 100.0) + sync._stage_incoming("remote", "text") + + for _ in range(20): + assert sync._pending_incoming is not None + sync._pending_incoming.next_attempt = 0 + sync._attempt_pending_incoming() + assert sync._pending_incoming is not None + assert 100.0 < sync._pending_incoming.next_attempt <= 130.0 + assert sync._last_synced is None + assert sync._history.get_entries() == [] + + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: True) + assert sync.retry_incoming_now() + assert sync._pending_incoming is None + assert sync._last_synced == "remote" + assert sync._history.get_entries()[0].text == "remote" + assert sync.status_snapshot()["error"] is None + + +def test_manual_incoming_retry_obeys_pause(tmp_path, monkeypatch): + sync = _sync(tmp_path) + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: False) + sync._stage_incoming("remote", "text") + sync._settings.set("sync_paused", True) + written = [] + monkeypatch.setattr(sync, "_write_clipboard", lambda value: written.append(value) or True) + + assert not sync.retry_incoming_now() + assert written == [] + assert sync._pending_incoming is not None + + +def test_new_local_copy_invalidates_old_remote_retry(tmp_path, monkeypatch): + sync = _sync(tmp_path) + sync._last_observed_clipboard = "local-old" + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: False) + sync._stage_incoming("remote-old", "text") + assert sync._pending_incoming is not None + + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", lambda: "local-new") + published: list[str] = [] + monkeypatch.setattr(sync, "_write_file", published.append) + sync._out_tick() + + assert published == ["local-new"] + assert sync._last_synced == "local-new" + assert sync._pending_incoming is None + + +@pytest.mark.parametrize( + ("old_value", "old_kind", "new_value", "new_kind"), + [("old text", "text", PNG_B, "image"), (PNG_A, "image", "new text", "text")], +) +def test_new_remote_type_invalidates_retry_of_other_type( + tmp_path, monkeypatch, old_value, old_kind, new_value, new_kind +): + sync = _sync(tmp_path) + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: False) + monkeypatch.setattr(sync, "_write_clipboard_image", lambda _value: False) + sync._stage_incoming(old_value, old_kind) + + written: list[object] = [] + if new_kind == "text": + monkeypatch.setattr(sync, "_write_clipboard", lambda value: written.append(value) or True) + else: + monkeypatch.setattr(sync, "_write_clipboard_image", lambda value: written.append(value) or True) + sync._stage_incoming(new_value, new_kind) + + assert written == [new_value] + assert sync._last_synced == new_value + assert sync._pending_incoming is None + + +def test_debounce_guarantees_trailing_latest_read(tmp_path, monkeypatch): + sync = _sync(tmp_path) + handler = _ClipboardFileHandler(sync) + path = str(sync.clipboard_file) + monkeypatch.setattr(handler, "_matches", lambda _path: True) + + handler._dispatch(path) + handler._dispatch(path) + assert sync._in_queue.get_nowait() == path + time.sleep(0.15) + assert sync._in_queue.get_nowait() == path + + +def test_resume_chooses_newer_format_only(tmp_path, monkeypatch): + sync = _sync(tmp_path) + sync.clipboard_file.write_text("older text", encoding="utf-8") + sync.clipboard_image_file.write_bytes(PNG_A) + now = time.time_ns() + os.utime(sync.clipboard_file, ns=(now - 2_000_000, now - 2_000_000)) + os.utime(sync.clipboard_image_file, ns=(now, now)) + text_writes: list[str] = [] + image_writes: list[bytes] = [] + monkeypatch.setattr(sync, "_write_clipboard", lambda value: text_writes.append(value) or True) + monkeypatch.setattr(sync, "_write_clipboard_image", lambda value: image_writes.append(value) or True) + + sync.reconcile_latest() + + assert image_writes == [PNG_A] + assert text_writes == [] + + +def test_overlapping_outbound_read_cannot_echo_incoming_write(tmp_path, monkeypatch): + sync = _sync(tmp_path) + sync._last_synced = "local-old" + sync._last_observed_clipboard = "local-old" + read_started = threading.Event() + release_read = threading.Event() + + def delayed_read() -> str: + read_started.set() + assert release_read.wait(2) + return "local-old" + + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", delayed_read) + published: list[str] = [] + monkeypatch.setattr(sync, "_write_file", published.append) + monkeypatch.setattr(sync, "_write_clipboard", lambda _value: True) + + thread = threading.Thread(target=sync._out_tick) + thread.start() + assert read_started.wait(2) + sync._stage_incoming("remote-new", "text") + release_read.set() + thread.join(2) + + assert not thread.is_alive() + assert sync._last_synced == "remote-new" + assert published == [] diff --git a/tests/test_file_transfer_encryption.py b/tests/test_file_transfer_encryption.py index dda5a24..8aedd76 100644 --- a/tests/test_file_transfer_encryption.py +++ b/tests/test_file_transfer_encryption.py @@ -30,7 +30,7 @@ encrypt_file, is_encrypted_file, ) -from clipsync.file_transfer import ENCRYPTED_SUFFIX, FileTransfer +from clipsync.file_transfer import ENCRYPTED_SUFFIX, FileTransfer, _FileReceiveHandler class _Settings: @@ -221,3 +221,46 @@ def test_encrypted_send_roundtrips_a_large_file_in_bounded_memory(tmp_path): def test_set_file_permissions_is_available_to_file_transfer(): """send() relies on it for both paths.""" assert callable(config.set_file_permissions) + + +def test_send_rejects_symlinks_and_oversized_files(tmp_path, monkeypatch): + transfer = _transfer(tmp_path, "") + target = tmp_path / "target.txt" + target.write_text("data") + if os.name == "nt": + pytest.skip("creating symlinks requires Windows developer mode or elevated privileges") + link = tmp_path / "link.txt" + link.symlink_to(target) + with pytest.raises(ValueError, match="regular files"): + transfer.send(link) + + monkeypatch.setattr("clipsync.file_transfer.MAX_FILE_TRANSFER_BYTES", 3) + with pytest.raises(ValueError, match="transfer limit"): + transfer.send(target) + + +def test_receive_rejects_unexpected_paths_oversized_files_and_bursts(tmp_path, monkeypatch): + root = tmp_path / "files" + delivered = [] + handler = _FileReceiveHandler(on_received=lambda path, _sender: delivered.append(path), files_dir=root) + + nested = root / "peer" / "nested" / "bad.txt" + nested.parent.mkdir(parents=True) + nested.write_text("bad") + handler._handle(nested) + assert delivered == [] + + oversized = root / "peer" / "large.txt" + oversized.parent.mkdir(exist_ok=True) + oversized.write_text("long") + monkeypatch.setattr("clipsync.file_transfer.MAX_FILE_TRANSFER_BYTES", 3) + handler._handle(oversized) + assert delivered == [] + + monkeypatch.setattr("clipsync.file_transfer.MAX_FILE_TRANSFER_BYTES", 100) + monkeypatch.setattr("clipsync.file_transfer.MAX_INCOMING_FILES_PER_MINUTE", 2) + for name in ("one.txt", "two.txt", "three.txt"): + incoming = root / "peer" / name + incoming.write_text(name) + handler._handle(incoming) + assert [path.name for path in delivered] == ["one.txt", "two.txt"] diff --git a/tests/test_product_improvements.py b/tests/test_product_improvements.py new file mode 100644 index 0000000..e196687 --- /dev/null +++ b/tests/test_product_improvements.py @@ -0,0 +1,275 @@ +"""Regression coverage for privacy, history, status, and transfer lifecycle.""" + +from __future__ import annotations + +import io +import time +from pathlib import Path + +import pytest +from PIL import Image + +from clipsync import config +from clipsync.clipboard import ClipboardSync +from clipsync.file_transfer import FileTransfer, _FileReceiveHandler +from clipsync.history import ClipboardHistory + + +def _settings(tmp_path) -> config.Settings: + settings = config.Settings(tmp_path / "settings.json") + settings.set("sync_folder", str(tmp_path / "sync")) + return settings + + +def test_idle_history_expiration_is_persisted(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("history_auto_clear_minutes", 1) + history = ClipboardHistory(settings) + monkeypatch.setattr("clipsync.history.time.time", lambda: 1_000.0) + history.add_entry("expires", "local") + + monkeypatch.setattr("clipsync.history.time.time", lambda: 1_061.0) + assert history.expire_now() + assert history.get_entries() == [] + assert ClipboardHistory(settings).get_entries() == [] + + +def test_pinned_history_still_obeys_privacy_expiration(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("history_auto_clear_minutes", 1) + history = ClipboardHistory(settings) + monkeypatch.setattr("clipsync.history.time.time", lambda: 1_000.0) + history.add_entry("pinned secret") + entry = history.get_entries()[0] + assert history.set_pinned(entry.timestamp, True) + + monkeypatch.setattr("clipsync.history.time.time", lambda: 1_061.0) + history.expire_now() + assert history.get_entries() == [] + + +def test_image_history_stores_bounded_thumbnail(tmp_path): + settings = _settings(tmp_path) + image = Image.new("RGB", (1200, 900), "red") + payload = io.BytesIO() + image.save(payload, format="PNG") + history = ClipboardHistory(settings) + + history.add_image(payload.getvalue(), "remote") + + entry = history.get_entries()[0] + assert entry.kind == "image" + assert entry.image_b64 + assert len(entry.image_b64) < 700_000 + + +def test_manual_send_requires_explicit_send(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("manual_send", True) + sync = ClipboardSync(settings) + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", lambda: "manual") + published: list[str] = [] + monkeypatch.setattr(sync, "_write_file", published.append) + + sync._out_tick() + assert published == [] + sync.send_current() + assert published == ["manual"] + assert sync.status_snapshot()["replication"] == "Waiting for Syncthing" + + +@pytest.mark.parametrize("secret", ["password = example-secret", "-----BEGIN OPENSSH PRIVATE KEY-----"]) +def test_secret_filter_blocks_outgoing_text_and_history(tmp_path, monkeypatch, secret): + settings = _settings(tmp_path) + settings.set("filter_likely_secrets", True) + sync = ClipboardSync(settings) + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", lambda: secret) + sync.send_current() + + assert not sync.clipboard_file.exists() + assert sync._history.get_entries() == [] + assert "privacy filter" in str(sync.status_snapshot()["error"]) + + +def test_secret_filter_blocks_incoming_and_allows_it_when_disabled(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("filter_likely_secrets", True) + sync = ClipboardSync(settings) + sync.clipboard_file.parent.mkdir(parents=True, exist_ok=True) + sync.clipboard_file.write_text("api_key = example-secret", encoding="utf-8") + written = [] + monkeypatch.setattr(sync, "_write_clipboard", lambda value: written.append(value) or True) + + sync._on_text_file_changed() + assert written == [] + assert sync._history.get_entries() == [] + assert "filter" in str(sync.status_snapshot()["error"]) + + settings.set("filter_likely_secrets", False) + sync._on_text_file_changed() + assert written == ["api_key = example-secret"] + assert sync.status_snapshot()["error"] is None + + +def test_disabling_history_still_publishes_but_does_not_save_new_clips(tmp_path, monkeypatch): + settings = _settings(tmp_path) + sync = ClipboardSync(settings) + sync._history.add_entry("existing") + settings.set("history_enabled", False) + sync.refresh_settings() + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", lambda: "new") + + sync.send_current() + + assert sync.clipboard_file.read_text(encoding="utf-8") == "new" + assert [entry.text for entry in sync._history.get_entries()] == ["existing"] + + +def test_timed_pause_reconciles_changes_when_it_expires(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("paused_until", time.time() + 60) + sync = ClipboardSync(settings) + reconciled: list[bool] = [] + monkeypatch.setattr(sync, "reconcile_latest", lambda: reconciled.append(True)) + + assert sync._refresh_pause_state(was_paused=True) + assert reconciled == [] + settings.set("paused_until", time.time() - 1) + assert not sync._refresh_pause_state(was_paused=True) + assert reconciled == [True] + + +def test_oversized_text_is_not_published(tmp_path, monkeypatch): + settings = _settings(tmp_path) + settings.set("max_text_bytes", 1024) + sync = ClipboardSync(settings) + monkeypatch.setattr(sync, "_read_clipboard_image", lambda: None) + monkeypatch.setattr(sync, "_read_clipboard", lambda: "x" * 1025) + published: list[str] = [] + monkeypatch.setattr(sync, "_write_file", published.append) + + sync._out_tick() + + assert published == [] + assert "limit" in str(sync.status_snapshot()["error"]) + + +def test_transfer_ids_are_unique_and_ack_is_real_delivery_evidence(tmp_path): + settings = _settings(tmp_path) + transfer = FileTransfer(settings, on_received=lambda _path, _sender: True) + source = tmp_path / "report.txt" + source.write_text("hello") + + first = transfer.send(source) + second = transfer.send(source) + assert first != second + transfer_id = first.name.split("_", 1)[0] + before = next(item for item in transfer.status_snapshot() if item["id"] == transfer_id) + assert before["status"] == "transferring" + + ack = transfer._ack_path(transfer_id) + ack.parent.mkdir(parents=True) + ack.write_text("received") + after = next(item for item in transfer.status_snapshot() if item["id"] == transfer_id) + assert after["status"] == "delivered" + assert after["acknowledgements"] == 1 + + +def test_transfer_waits_for_every_expected_receiver(tmp_path): + settings = _settings(tmp_path) + transfer = FileTransfer( + settings, + on_received=lambda _path, _sender: True, + expected_receivers=lambda: 2, + ) + source = tmp_path / "report.txt" + source.write_text("hello") + + published = transfer.send(source) + transfer_id = published.name.split("_", 1)[0] + ack_dir = transfer._ack_path(transfer_id).parent + ack_dir.mkdir(parents=True) + (ack_dir / "laptop.ack").write_text("received") + pending = next(item for item in transfer.status_snapshot() if item["id"] == transfer_id) + assert pending["status"] == "transferring" + assert pending["acknowledgements"] == 1 + + (ack_dir / "desktop.ack").write_text("received") + delivered = next(item for item in transfer.status_snapshot() if item["id"] == transfer_id) + assert delivered["status"] == "delivered" + assert delivered["acknowledgements"] == 2 + + +def test_restart_recovers_unacknowledged_incoming_file(tmp_path): + settings = _settings(tmp_path) + transfer = FileTransfer(settings, on_received=lambda _path, _sender: True) + incoming = transfer.files_dir / "peer-host" / "report.txt" + incoming.parent.mkdir(parents=True) + incoming.write_text("hello") + handler = _FileReceiveHandler( + on_received=transfer._enqueue_receive, + files_dir=transfer.files_dir, + ) + + transfer._recover_pending(handler) + + path, sender, attempt = transfer._receive_queue.get_nowait() + assert (path, sender, attempt) == (incoming, "peer-host", 0) + + +def test_receive_ack_waits_for_success_and_retries_without_duplicate_save(tmp_path, monkeypatch): + settings = _settings(tmp_path) + incoming = tmp_path / "sync" / "files" / "peer-host" / "report.txt" + incoming.parent.mkdir(parents=True) + incoming.write_text("hello") + save_results = iter((False, True)) + saves: list[Path] = [] + + def save(path, _sender): + saves.append(path) + return next(save_results) + + scheduled: list[tuple[object, tuple[object, ...]]] = [] + + class ImmediateTimer: + daemon = False + + def __init__(self, _delay, callback, args=()): + self.callback = callback + self.args = args + + def start(self): + scheduled.append((self.callback, self.args)) + + monkeypatch.setattr("clipsync.file_transfer.threading.Timer", ImmediateTimer) + transfer = FileTransfer(settings, on_received=save) + transfer._receive_once(incoming, "peer-host", 0) + assert not transfer._ack_path(transfer._transfer_id(incoming)).exists() + + callback, args = scheduled.pop(0) + callback(*args) + path, sender, attempt = transfer._receive_queue.get_nowait() + transfer._receive_once(path, sender, attempt) + assert transfer._ack_path(transfer._transfer_id(incoming)).exists() + assert saves == [incoming, incoming] + + real_permissions = config.set_file_permissions + permission_attempts = 0 + + def flaky_permissions(path): + nonlocal permission_attempts + permission_attempts += 1 + if permission_attempts == 1: + raise OSError("temporarily read-only") + real_permissions(path) + + transfer._ack_path(transfer._transfer_id(incoming)).unlink() + monkeypatch.setattr(config, "set_file_permissions", flaky_permissions) + transfer._write_ack(incoming) + callback, args = scheduled.pop(0) + callback(*args) + assert transfer._ack_path(transfer._transfer_id(incoming)).exists() + assert saves == [incoming, incoming] diff --git a/tests/test_secret_storage.py b/tests/test_secret_storage.py new file mode 100644 index 0000000..6cf6f96 --- /dev/null +++ b/tests/test_secret_storage.py @@ -0,0 +1,71 @@ +"""Encryption passphrases belong in the OS keychain, never settings.json.""" + +from __future__ import annotations + +import json + +from clipsync import config, secure_settings + + +def test_new_passphrase_is_stored_outside_settings_json(tmp_path) -> None: + path = tmp_path / "settings.json" + settings = config.Settings(path=path) + + settings.set_passphrase("correct horse battery staple") + + assert settings.get_passphrase() == "correct horse battery staple" + assert "correct horse battery staple" not in path.read_text() + assert "encryption_passphrase" not in json.loads(path.read_text()) + + +def test_legacy_plaintext_passphrase_is_migrated_and_removed(tmp_path) -> None: + path = tmp_path / "settings.json" + path.write_text(json.dumps({**config.DEFAULT_SETTINGS, "encryption_passphrase": "legacy-secret"})) + + settings = config.Settings(path=path) + + assert settings.get_passphrase() == "legacy-secret" + assert "encryption_passphrase" not in json.loads(path.read_text()) + + +def test_legacy_value_remains_available_if_keychain_is_unavailable(tmp_path, monkeypatch) -> None: + path = tmp_path / "settings.json" + path.write_text(json.dumps({**config.DEFAULT_SETTINGS, "encryption_passphrase": "legacy-secret"})) + + def unavailable(): + raise config.SecretStorageError("not available") + + monkeypatch.setattr(config, "_keyring_backend", unavailable) + settings = config.Settings(path=path) + + assert settings.get_passphrase() == "legacy-secret" + assert json.loads(path.read_text())["encryption_passphrase"] == "legacy-secret" + + +def test_upgrade_preserves_older_keychain_passphrase_and_clear_removes_it(tmp_path) -> None: + path = tmp_path / "settings.json" + namespace = str(path.resolve()) + secure_settings.set_passphrase("previous-release-secret", namespace) + + settings = config.Settings(path=path) + assert settings.get_passphrase() == "previous-release-secret" + assert "previous-release-secret" not in path.read_text() + + settings.set_passphrase("") + assert config.Settings(path=path).get_passphrase() == "" + assert secure_settings.get_passphrase(namespace) is None + + +def test_upgrade_preserves_older_encrypted_fallback_without_keychain(tmp_path, monkeypatch) -> None: + path = tmp_path / "settings.json" + namespace = str(path.resolve()) + monkeypatch.setattr(secure_settings, "_read_machine_secret", lambda: b"test-machine") + secure_settings._fallback_set("fallback-secret", namespace) + + def unavailable(): + raise config.SecretStorageError("not available") + + monkeypatch.setattr(config, "_keyring_backend", unavailable) + settings = config.Settings(path=path) + assert settings.get_passphrase() == "fallback-secret" + assert "fallback-secret" not in path.read_text() diff --git a/tests/test_secure_settings.py b/tests/test_secure_settings.py index d7c9579..3a52c5b 100644 --- a/tests/test_secure_settings.py +++ b/tests/test_secure_settings.py @@ -16,7 +16,7 @@ def test_passphrase_is_not_persisted_in_settings_json(tmp_path) -> None: settings.set("encryption_passphrase", "my-secret-passphrase") persisted = json.loads(path.read_text()) - assert persisted.get("encryption_passphrase") == "" + assert persisted.get("encryption_passphrase", "") == "" assert settings.get("encryption_passphrase") == "my-secret-passphrase" @@ -28,7 +28,7 @@ def test_plaintext_passphrase_is_migrated_on_load(tmp_path) -> None: assert settings.get("encryption_passphrase") == "old-plaintext" persisted = json.loads(path.read_text()) - assert persisted.get("encryption_passphrase") == "" + assert persisted.get("encryption_passphrase", "") == "" def test_clearing_passphrase_removes_secure_storage(tmp_path) -> None: diff --git a/tests/test_syncthing_restart.py b/tests/test_syncthing_restart.py new file mode 100644 index 0000000..4616617 --- /dev/null +++ b/tests/test_syncthing_restart.py @@ -0,0 +1,56 @@ +"""Unit tests for Syncthing restart behavior.""" + +from clipsync import syncthing + + +def test_restart_delay_doubles_and_is_bounded() -> None: + delay = syncthing._RESTART_DELAY + assert syncthing._next_restart_delay(delay) == delay * 2 + assert syncthing._next_restart_delay(syncthing._RESTART_DELAY_MAX // 2) == syncthing._RESTART_DELAY_MAX + assert syncthing._next_restart_delay(syncthing._RESTART_DELAY_MAX) == syncthing._RESTART_DELAY_MAX + + +class _CrashedProcess: + def poll(self) -> int: + return 1 + + +class _StopAfterThreeWaits: + def __init__(self) -> None: + self.delays: list[int] = [] + + def is_set(self) -> bool: + return False + + def wait(self, delay: int) -> bool: + self.delays.append(delay) + return len(self.delays) == 3 + + +class _ReadyClient: + def wait_until_ready(self) -> bool: + return True + + +def test_watch_resets_delay_after_ready_restart_then_backs_off(tmp_path, monkeypatch) -> None: + """A ready restart resets the next crash to the short retry delay.""" + service = syncthing.SyncthingService(syncthing.config.Settings(path=tmp_path / "settings.json")) + stop = _StopAfterThreeWaits() + service._proc = _CrashedProcess() # type: ignore[assignment] + service._stop = stop # type: ignore[assignment] + service.client = _ReadyClient() # type: ignore[assignment] + + attempts = 0 + + def spawn() -> None: + nonlocal attempts + attempts += 1 + if attempts == 2: + raise OSError("still unavailable") + + monkeypatch.setattr(service, "_spawn", spawn) + + service._watch() + + assert attempts == 2 + assert stop.delays == [10, 10, 20] diff --git a/uv.lock b/uv.lock index e87305d..44db972 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,15 @@ resolution-markers = [ "python_full_version < '3.12'", ] +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -274,6 +283,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f2/f2/728f041460f1b9739b85ee23b45fa5a505962ea11fd85bdbe2a02b021373/darkdetect-0.8.0-py3-none-any.whl", hash = "sha256:a7509ccf517eaad92b31c214f593dbcf138ea8a43b2935406bbd565e15527a85", size = 8955, upload-time = "2022-12-16T14:14:40.92Z" }, ] +[[package]] +name = "evdev" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2e/07/eb30593524303d1367f0dc00c1c66582c994dba412a47751e34bad9fa29b/evdev-2.0.0.tar.gz", hash = "sha256:442fb3f4c8dfc9e61e901133c356220c02d663eca8f34722e0cecdd637eba504", size = 33766, upload-time = "2026-08-23T10:31:26.835Z" } + [[package]] name = "idna" version = "3.18" @@ -283,6 +298,90 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "importlib-metadata" +version = "9.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/7e/1e7e8dc30634b93ebb3d58a3dea569ad146e656218d3960ab04f62047b29/importlib_metadata-9.0.1.tar.gz", hash = "sha256:ab830580bc0ef3db61ce8fae716389e5462b67e033018bab6d8f80ef17172f99", size = 59124, upload-time = "2026-08-28T15:30:34.646Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/55/ecca97ae19075f1fac62def77731e7f535e6c1fb8f92ff08160c5e6dade8/importlib_metadata-9.0.1-py3-none-any.whl", hash = "sha256:bba5600596a7e21f3eef53281cf28d6a5195634d2f2b78ff9501a3272c6eaab0", size = 27920, upload-time = "2026-08-28T15:30:33.433Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + [[package]] name = "numpy" version = "2.4.6" @@ -425,8 +524,10 @@ source = { editable = "." } dependencies = [ { name = "cryptography" }, { name = "customtkinter" }, + { name = "keyring" }, { name = "opencv-python" }, { name = "pillow" }, + { name = "pynput" }, { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, { name = "pyperclip" }, @@ -440,8 +541,10 @@ dependencies = [ requires-dist = [ { name = "cryptography", specifier = ">=41.0.0" }, { name = "customtkinter", specifier = ">=5.2.2" }, + { name = "keyring", specifier = ">=25.0.0" }, { name = "opencv-python", specifier = ">=4.9.0.80" }, { name = "pillow", specifier = ">=10.0.0" }, + { name = "pynput", specifier = ">=1.7.7" }, { name = "pyobjc-core", marker = "sys_platform == 'darwin'", specifier = ">=10.0" }, { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'", specifier = ">=10.0" }, { name = "pyperclip", specifier = ">=1.9.0" }, @@ -574,6 +677,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, ] +[[package]] +name = "pynput" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/c6/e2d415610cfbc78308bee44218a46124aaa3301b1df08814df819b2254a1/pynput-1.8.2.tar.gz", hash = "sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087", size = 82818, upload-time = "2026-05-12T19:11:39.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl", hash = "sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1", size = 92028, upload-time = "2026-05-12T19:11:37.89Z" }, +] + [[package]] name = "pyobjc-core" version = "12.2.1" @@ -590,6 +709,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/a8/cb9fcc150f97d0bf22a2028f88b24cc35949beb1bcc7b8bc5c17d4401677/pyobjc_core-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1188613805336270279570467e4455b74cb6c0f60913ac74c917ee1c37cfaecb", size = 6733064, upload-time = "2026-06-19T16:05:14.313Z" }, ] +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/4d/0ebdd8144aba94b8fe9828ccee5616a4bf53d1f8bc51cff55f3cce86d695/pyobjc_framework_applicationservices-12.2.1.tar.gz", hash = "sha256:048ea663c9ac75c44a15dc7d5b8d78cbb4c97bf1c76e83835e8d5498e184001f", size = 109342, upload-time = "2026-06-19T16:19:46.149Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cd/8a/5a9310929bb303c31c04a1c6dc7b3213c9bd964a692d024a00c0643af2c8/pyobjc_framework_applicationservices-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dabec481217b0d0c1ea835e9fef6b0681381b14b3f16a30d4a9d801ee3852dd2", size = 32715, upload-time = "2026-06-19T16:05:38.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/89/39a7462006afbc06c69029fe4181b7359a9da25ae7864ef75f9d3ffb9272/pyobjc_framework_applicationservices-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:f519ced13888d03410cd7da1f08fc56ee2944099e607216cef7ca26ecfdef61b", size = 32764, upload-time = "2026-06-19T16:05:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6e/8e928d5e3025529ed92c6eb5fd88a5e6e485cc6df945c541f29b4af7f2c6/pyobjc_framework_applicationservices-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8749290f796e6cca341d443769b79329dde5d157bcc4413c1f7fdb68ea4a8e48", size = 32782, upload-time = "2026-06-19T16:05:40.284Z" }, + { url = "https://files.pythonhosted.org/packages/50/a5/c7b5a31777fe2ce7c07b9c16941ff4fbf0a150bf755164d96228d32ccb4f/pyobjc_framework_applicationservices-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9ee11677fbd6a0987234814c7dde88ffd11242e8c1f76952e6654ea07f2370ac", size = 33048, upload-time = "2026-06-19T16:05:41.308Z" }, + { url = "https://files.pythonhosted.org/packages/8b/47/cd2bd76b862686c0aa78568ed9dff175764353c209a3096c72d6e2a9b151/pyobjc_framework_applicationservices-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a1c0ee536cb8bd7f5a811165ec323a9207b1e8dad9534fe2081f767fb90b0411", size = 32921, upload-time = "2026-06-19T16:05:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/6989a96f8501aa3a16513d08b2c4c78ca906a10b6a4e5a33c4c59fd1bea9/pyobjc_framework_applicationservices-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0d55dd5be19e4a1363662bc8b48894d45714d07f0ee3958665fc9ea7df0f61b7", size = 33163, upload-time = "2026-06-19T16:05:43.256Z" }, + { url = "https://files.pythonhosted.org/packages/5c/41/66f2bcd12454a85f0479393f5825021332ee84b49583c7954cd20310cab5/pyobjc_framework_applicationservices-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:e91c84238b2f68f608473854bcabb8770f15ad837e7561d217f24a1e482e42be", size = 32915, upload-time = "2026-06-19T16:05:44.151Z" }, + { url = "https://files.pythonhosted.org/packages/81/cf/04b6b1eb181fa3071e9743bab7551f5d2ec3f650ac74bab790f21717ba51/pyobjc_framework_applicationservices-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:09bfa27765d4c74155323fd8185b7aba6d639ce06c0a9f00ee2d9e7dce3d7800", size = 33158, upload-time = "2026-06-19T16:05:45.017Z" }, +] + [[package]] name = "pyobjc-framework-cocoa" version = "12.2.1" @@ -609,6 +750,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/f3/eedf743a303ea742b8e082afe3613fb4d6618bc1a48cf2568b004ce906f7/pyobjc_framework_cocoa-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c685ccd8e266a07cf912a2c5a13b1f2eff2a868a1aff163b4801b4687bd425e1", size = 392691, upload-time = "2026-06-19T16:07:47.477Z" }, ] +[[package]] +name = "pyobjc-framework-coretext" +version = "12.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/9c/4c7f452059dc1d3845b8e627b9113c247a997b9b07518e848c2ab7ff3149/pyobjc_framework_coretext-12.2.1.tar.gz", hash = "sha256:af740e784d7c592c34025ec7165f4f6c1a69b5a2d9075f06e41e4f77c212aed2", size = 97349, upload-time = "2026-06-19T16:20:22.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/53/c262cf5052c648c48b3f7562fcce188fb78ff94e44cf1c48fdfc62fdfcce/pyobjc_framework_coretext-12.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:02a448675d28005fdb88fb3c585572b02871e9e5d4d08f88334ec937ea5de6e7", size = 30022, upload-time = "2026-06-19T16:09:50.37Z" }, + { url = "https://files.pythonhosted.org/packages/c5/11/c1298c2ec3b0cd19a457a1fd0da47898f894a13df5516f80dc04d1a7a4d9/pyobjc_framework_coretext-12.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac2ead13dfa4379a1566129d0e8a8ea778a2bcac9ac360a583360fd4f1ba39c6", size = 30123, upload-time = "2026-06-19T16:09:51.183Z" }, + { url = "https://files.pythonhosted.org/packages/05/8c/154e8f34923b24aade64a20eca2b759f8f67e109654308103080751f246f/pyobjc_framework_coretext-12.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5c5a3c6e2d905a17efb15572dad97ce582feeab5c3b92537015445e0e0bb46de", size = 30116, upload-time = "2026-06-19T16:09:52.219Z" }, + { url = "https://files.pythonhosted.org/packages/01/61/f53458c8f7fe74008e342946eca1fa82b777b284d4e13d8bd2e3e5724cab/pyobjc_framework_coretext-12.2.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5c979058c77df8cd3dac5fd7db4c484f9886fbe09e2687bfaf269a856f631f78", size = 30659, upload-time = "2026-06-19T16:09:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d8/d1178bb1ba3bb7a0d7a55db460aa89f2a8b232ed7eaf76cb402923cacf2d/pyobjc_framework_coretext-12.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0b3b0467a23dbc2a39d0839e7100cc98b429fb7d52a471bd65477f46bb4c9e5", size = 30100, upload-time = "2026-06-19T16:09:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/12/c3/780d739909e6d8ddd9e8786fd19e8ea10ccfcc7275df987e349af0fb33b1/pyobjc_framework_coretext-12.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:3d4a92fa657180cd0e900b98535da4f0f4d8c76a7077730a507e50d52d2853e3", size = 30642, upload-time = "2026-06-19T16:09:54.736Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/867197c6cf2396b33e95d6175d7fdc6f789314859fb107560ae9b19c7b14/pyobjc_framework_coretext-12.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:7a17034fd0a08cf58323b7234a385ce0ec355509d414df69e3f8f88df26de1fc", size = 30098, upload-time = "2026-06-19T16:09:55.679Z" }, + { url = "https://files.pythonhosted.org/packages/10/a3/f4e6d1a38cd4db8a1275eddb287f3cdc2c01c48f80b30e89cc58cfd92156/pyobjc_framework_coretext-12.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:28980144af75598654f6997b2bbb427885f4f5df90aa4d840f452ba5778ab155", size = 30669, upload-time = "2026-06-19T16:09:56.521Z" }, +] + [[package]] name = "pyobjc-framework-quartz" version = "12.2.1" @@ -664,6 +826,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, ] +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + [[package]] name = "qrcode" version = "8.2" @@ -696,6 +867,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -740,3 +924,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, ] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]